Writing Functional Testing in Go

Many people write unit tests, but not everyone knows how to write functional ones. This article will include libraries, tips on functional tests, and most importantly, we will practice writing them using the example of Rest API

Functional testing

Functional testing is a type of testing where not a small part is tested, but the entire program, while the program itself does not know that it is being tested. Does it work correctly under certain conditions, what will it return, what will be the error, etc.

Moki

If your program works with any databases, you will have to use mocks. What are mocks? This is the replacement of real functions and objects with artificial ones, imitating the real ones, so as not to touch or access the DB

To understand how to write them, there is a good video on YouTube: https://www.youtube.com/watch?v=qaaa3RsC0FQ

As for the library, I use it mockery: github.com/vektra/mockerybut you can use any library convenient for you

Libraries

Here are some libraries for functional testing:

  1. Testify

  2. Govalidator

  3. Gofakeit

  4. Mockery

  5. Testing

We write tests

Now let's try to write functional tests. I have prepared files with RestApiso you can write tests with me. Here is a link to Yandex disk: https://disk.yandex.ru/d/XlY1bb4nyeLwqw

It is necessary to check the operation of a program that takes as input 2 numbers and a mathematical operation that must be performed on them and returns their result.

Preparation

But first, we need to set up the configs. So far, we have only 1: “local.yaml”, we need to create a second one: “local_tests.yaml”. We will leave all the same settings in it, except for one – timeout. We will change its value from 4s to 10h. What does this setting do? This is the maximum response time and if the application response time exceeds it when accessing it, an error will occur. For tests, it is better to set it higher, but in production – about 3-4 seconds.

Now let's create a suite/ folder in the tests/ folder and a suite.go file in it. In this file, we'll configure the receipt of the required config, as well as the testing itself.

Let's write the “Suite” structure:

type Suite struct {
	*testing.T // Управление тестами
	Cfg *config.Config // Конфиг
}

Now let's write the “New” function:

func New(t *testing.T) *Suite {

This function will return a pointer to the structure created above.

In the function we call 2 methods:

t.Helper() // Говорим, что функция New() не будет отображаться в тестах
t.Parallel() // Говорим, что будем вызывать тесты параллельно

Let's get the config:

cfg := config.MustLoadPath(configPath())

You need to create a function “configPath()”:

func configPath() string {
	const key = "CONFIG_PATH" 

	if v := os.Getenv(key); v != "" {
		return v
	}

	return "../config/local_tests.yaml"
}

In this function we get the path to the test config if it is not standard, otherwise we simply return the standard one

Let's return to the New() function

Let's return a pointer to the “Suite” structure:

return &Suite{
		T:   t,
		Cfg: cfg,
}

The final code for the suite.go file:

package suite

import (
	"os"
	"testing"

	"functional-testing/internal/config"
)

type Suite struct {
	*testing.T
	Cfg *config.Config
}

func New(t *testing.T) *Suite {
	t.Helper()
	t.Parallel()

	cfg := config.MustLoadPath(configPath())

	return &Suite{
		T:   t,
		Cfg: cfg,
	}
}

func configPath() string {
	const key = "CONFIG_PATH"

	if v := os.Getenv(key); v != "" {
		return v
	}

	return "../config/local_tests.yaml"
}

We exit the suite/ folder back to tests/ and create the file “math_test.go” there.

Let's write a “Result” structure in it, where we will store the result of our mathematical operation:

type Result struct {
	Result float64 `json:"result"`
}

And also the “generateRandomFloat()” function, which will generate a random floating point number:

func generateRandomFloat() float64 {
	random := rand.New(rand.NewSource(time.Now().UnixNano()))
	return random.Float64() * float64(random.Intn(100))
}

Here we declare the variable “result”. What does it do? Because we are using the standard “math/rand” package, we need to set up the “seed”, as “math/rand” generates pseudo-random numbers. Previously, it was necessary to call the “Seed()” method, but now we need to call the “New()” method together with “NewSource()” inside. If you don’t know how math/rand actually works and why we do this, there is good article: https://ru.linux-console.net/?p=28237

After the declaration, we use it to generate a random number and multiply it by another random number so that it is not in the range from 0.0 to 1.0. You can also read about this in the above mentioned article

We can move on to writing tests.

Testing is a happy accident

We will use JSON, since we have a Rest API

We will use JSON, since we have a Rest API

Let's start with the so-called “happy accident”. Let's write the function “TestMath_HappyPath(t *testing.T)”:

func TestMath_HappyPath(t *testing.T) {

This function will check the program on valid input data.

Let's write test cases for our program:

cases := []struct {
		Name string
		Num1 float64
		Num2 float64
		Op   string
	}{
		{
			Name: "Sum",
			Num1: randomFloat(),
			Num2: randomFloat(),
			Op:   "+",
		},
		{
			Name: "Sub",
			Num1: randomFloat(),
			Num2: randomFloat(),
			Op:   "-",
		},
		{
			Name: "Mul",
			Num1: randomFloat(),
			Num2: randomFloat(),
			Op:   "*",
		},
		{
			Name: "Div",
			Num1: randomFloat(),
			Num2: randomFloat(),
			Op:   "/",
		},
	}

Here we create a section of test cases, which consist of the name and operation that we will carry out

Let's get our “suite”:

st := suite.New
  {
    "operation": "%s",
    "num1": %v,
    "num2": %v
  }
  `, tc.Op, num1, num2))
resp, err := http.Post("http://"+st.Cfg.Addr+"/math",
"application/json",
request)

Here we create a buffer in which we will store json and make a post request to our program with a header equal to “application/json”, that is, we say that we are transmitting json

We use the “testify” package and check the response for errors:

require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)

A little bit about “testify”

When using testify you will use 2 modules: “require” and “assert”. Their differences are that, for example, if there is an error when calling require.NoError(), it will simply end the current test, unlike assert, which will return a boolean

Let's continue

Let's say that at the end of the current test the response body is closed:

defer resp.Body.Close()

We read the answer and check for errors while reading:

res, err := io.ReadAll(resp.Body)
require.NoError(t, err)

We declare the variable “result”:

var result Result

We transform the response body from json into a “Result” structure and write it into the “result” variable, and, of course, don’t forget to check for errors:

err = json.Unmarshal(res, &result)
require.NoError(t, err)

Now, based on the mathematical operation recorded in the test case, we perform it and compare it with the answer:

switch tc.Op {
case "+":
	assert.Equal(t, tc.Num1+tc.Num2, result.Result)
case "-":
	assert.Equal(t, tc.Num1-tc.Num2, result.Result)
case "*":
	assert.Equal(t, tc.Num1*tc.Num2, result.Result)
case "/":
	assert.Equal(t, tc.Num1/tc.Num2, result.Result)
}

Here we use “assert”

Here is the entire “math_test.go” file:

package tests

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"functional-testing/tests/suite"
)

type Result struct {
	Result float64 `json:"result"`
}

func TestMath_HappyPath(t *testing.T) {
	cases := []struct {
		Name string
		Num1 float64
		Num2 float64
		Op   string
	}{
		{
			Name: "Sum",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "+",
		},
		{
			Name: "Sub",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "-",
		},
		{
			Name: "Mul",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "*",
		},
		{
			Name: "Div",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "/",
		},
	}

	st := suite.New

	for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			request := bytes.NewBufferString(fmt.Sprintf(`
            {
              "operation": "%s",
                "num1": %v,
                "num2": %v
            }
            `, tc.Op, tc.Num1, tc.Num2))

			resp, err := http.Post("http://"+st.Cfg.Addr+"/math",
				"application/json",
				request)

			require.NoError(t, err)
			require.Equal(t, http.StatusOK, resp.StatusCode)

			defer resp.Body.Close()

			res, err := io.ReadAll(resp.Body)
			require.NoError(t, err)

			var result Result

			err = json.Unmarshal(res, &result)
			require.NoError(t, err)

			switch tc.Op {
			case "+":
				assert.Equal(t, tc.Num1+tc.Num2, result.Result)
			case "-":
				assert.Equal(t, tc.Num1-tc.Num2, result.Result)
			case "*":
				assert.Equal(t, tc.Num1*tc.Num2, result.Result)
			case "/":
				assert.Equal(t, tc.Num1/tc.Num2, result.Result)
			}
		})
	}
}


func generateRandomFloat() float64 {
	random := rand.New(rand.NewSource(time.Now().UnixNano()))
	return random.Float64() * float64(random.Intn(100))
}

Testing – errors

We've tested “happy accidents,” but it's better to test failures and errors. Let's do that!

Let's write the function “TestMath_FailCases(t *testing.T)”:

func TestMath_FailCases(t *testing.T) {

We will also create test cases in it:

cases := []struct {
		Name           string
		Num1           interface{}
		Num2           interface{}
		Op             string
		ExpectedStatus int
	}{
		{
			Name:           "Sum_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "+",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "Sub_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "-",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "Mul_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "*",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "Div_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "/",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "InvalidOperation",
			Num1:           generateRandomFloat(),
			Num2:           generateRandomFloat(),
			Op:             "invalid",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "BothInvalid",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "invalid",
			ExpectedStatus: http.StatusBadRequest,
		},
	}

The more test cases – the better. Our program is small – so these are all that can be written (but if you find more – write about it in the comments)

The code below is very similar to what we already wrote, but with some minor differences.

st := suite.New

for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			request := bytes.NewBufferString(fmt.Sprintf(`
            {
              "operation": "%s",
              "num1": %v,
              "num2": %v
            }
            `, tc.Op, tc.Num1, tc.Num2))

            resp, err := http.Post("http://"+st.Cfg.Addr+"/math",
				"application/json",
				request)

            require.NoError(t, err)

From this point on, differences begin to appear.

defer resp.Body.Close()
require.Equal(t, tc.ExpectedStatus, resp.StatusCode)

We do not check for compliance with the OK status. We check for the expected code. In our tests, there is only 1 – StatusBadRequest, but in many programs they are different, so we wrote them in the test cases.

This is what the code for the file “math_test.go” now looks like:

package tests

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"functional-testing/tests/suite"
)

type Result struct {
	Result float64 `json:"result"`
}

func TestMath_HappyPath(t *testing.T) {
	cases := []struct {
		Name string
		Num1 float64
		Num2 float64
		Op   string
	}{
		{
			Name: "Sum",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "+",
		},
		{
			Name: "Sub",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "-",
		},
		{
			Name: "Mul",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "*",
		},
		{
			Name: "Div",
			Num1: generateRandomFloat(),
			Num2: generateRandomFloat(),
			Op:   "/",
		},
	}

	st := suite.New

	for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			request := bytes.NewBufferString(fmt.Sprintf(`
            {
              "operation": "%s",
              "num1": %v,
              "num2": %v
            }
            `, tc.Op, tc.Num1, tc.Num2))

			resp, err := http.Post("http://"+st.Cfg.Addr+"/math",
				"application/json",
				request)

			require.NoError(t, err)
			require.Equal(t, http.StatusOK, resp.StatusCode)

			defer resp.Body.Close()

			res, err := io.ReadAll(resp.Body)
			require.NoError(t, err)

			var result Result

			err = json.Unmarshal(res, &result)
			require.NoError(t, err)

			switch tc.Op {
			case "+":
				assert.Equal(t, tc.Num1+tc.Num2, result.Result)
			case "-":
				assert.Equal(t, tc.Num1-tc.Num2, result.Result)
			case "*":
				assert.Equal(t, tc.Num1*tc.Num2, result.Result)
			case "/":
				assert.Equal(t, tc.Num1/tc.Num2, result.Result)
			}
		})
	}
}

func TestMath_FailCases(t *testing.T) {
	cases := []struct {
		Name           string
		Num1           interface{}
		Num2           interface{}
		Op             string
		ExpectedStatus int
	}{
		{
			Name:           "Sum_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "+",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "Sub_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "-",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "Mul_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "*",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "Div_InvalidNumbers",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "/",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "InvalidOperation",
			Num1:           generateRandomFloat(),
			Num2:           generateRandomFloat(),
			Op:             "invalid",
			ExpectedStatus: http.StatusBadRequest,
		},
		{
			Name:           "BothInvalid",
			Num1:           "not a number",
			Num2:           "not a number",
			Op:             "invalid",
			ExpectedStatus: http.StatusBadRequest,
		},
	}

	st := suite.New

	for _, tc := range cases {
		t.Run(tc.Name, func(t *testing.T) {
			t.Parallel()

			request := bytes.NewBufferString(fmt.Sprintf(`
            {
              "operation": "%s",
              "num1": %v,
              "num2": %v
            }
            `, tc.Op, tc.Num1, tc.Num2))

			resp, err := http.Post("http://"+st.Cfg.Addr+"/math",
				"application/json",
				request)

			require.NoError(t, err)
			defer resp.Body.Close()

			require.Equal(t, tc.ExpectedStatus, resp.StatusCode)
		})
	}
}

func generateRandomFloat() float64 {
	random := rand.New(rand.NewSource(time.Now().UnixNano()))
	return random.Float64() * float64(random.Intn(100))
}

Testing

Let's test our program. To do this, run our application using the command “go run cmd/web/*.go”. After that, in another console, go to the tests/ folder and run the command “go test -v”. If your output matches mine, then congratulations, you wrote everything correctly, if not, check with my tests.

Conclusion:

PASS ok functional-testing/tests 0.010s

Now you can try to write these same tests yourself, for practice.

Conclusion

I spent a lot of time on this article and I hope that you understand how to write functional tests and will use them in your programs!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *