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