Написание функционального тестирования в Go

Многие пишут юнит-тесты, но не все знают, как писать функциональные. В этой статье будут библиотеки, фишки про функциональные тесты, а самое главное — попрактикуемся их писать на примере Rest API

Функциональное тестирование

9b6007af2763222a8399087c93911b92.png

Функциональное тестирование — это такой тип тестирования, когда проверяется не маленькая часть, а вся программа, при этом сама программа не знает о том, что ее тестируюют. Правильно ли она работает при определенных условиях, что вернет, какая будет ошибка и т.д

Моки

Если Ваша программа работает с какими-нибудь базами данных, то придется использовать моки. Что такое моки? Это подмена реальных функций и объектов на искусственные, имитируя настоящие, чтобы не затрагивать и не обращаться к БД

Чтобы понять, как их писать, есть хорошее видео на ютубе: https://www.youtube.com/watch? v=qaaa3RsC0FQ

Насчет библиотеки, я пользуюсь mockery: github.com/vektra/mockery, но Вы можете использовать любую удобную Вам библиотеку

Библиотеки

Вот несколько библиотек для функционального тестирования:

  1. Testify

  2. Govalidator

  3. Gofakeit

  4. Mockery

  5. Testing

Пишем тесты

Теперь попробуем написать функциональные тесты. Я подготовил файлы с RestApi, чтобы Вы могли писать тесты вместе со мной. Вот ссылка на яндекс диск: https://disk.yandex.ru/d/XlY1bb4nyeLwqw

Надо проверить работу программы, которая принимает на вход 2 числа и математическую операцию, которую необходимо выполнить над ними и возвращает их результат.

Подготовка

Но, сначала надо настроить конфиги. Пока что у нас есть только 1: «local.yaml», необходимо создать второй: «local_tests.yaml». В нем оставим все те же настройки, кроме одной — timeout. Изменим ее значение с 4s на 10h. Что делает это настройка? Это максимальное время отклика и если при обращении к приложению время его отклика превысит его — возникнет ошибка. Для тестов — лучше ставить побольше, но в продакшене — около 3–4 секунд.

Теперь создадим в папке tests/ папку suite/ и в ней файл suite.go. в этом файле настроим получение нужного конфига, а еще само тестирование

Напишем структуру «Suite»:

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

Теперь напишем функцию «New»:

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

Эта функция будет возвращать указатель на выше созданную структуру

В функции вызовем 2 метода:

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

Получим конфиг:

cfg := config.MustLoadPath(configPath())

Надо создать функцию «configPath ()»:

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

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

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

В этой функции мы получаем путь к тестовому конфигу если он не стандартный, иначе просто возвращаем стандартный

Вернемся к функции New ()

Вернем указатель на структуру «Suite»:

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

Итоговый код файла suite.go:

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"
}

Выходим из папки suite/ обратно в tests/ и создаем там файл «math_test.go»

Напишем в нем структуру «Result», где будем хранить результат нашей математической операции:

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

А также функцию «generateRandomFloat ()», которая будет генерировать случайное число с плавающей точкой:

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

Здесь объявляем переменную «result». Что она делает? Из-за того, что мы используем стандартный пакет «math/rand» — нам необходимо настроить «seed», так как «math/rand» генерирует псевдо-случайные числа. Раньше необходимо было вызывать метод «Seed ()», но сейчас надо вызывать метод «New ()» вместе с «NewSource ()» внутри. Если Вы не знаете, как работает math/rand на самом деле и зачем мы так делаем — есть хорошая статья: https://ru.linux-console.net/? p=28237

После объявления используем ее для генерации случайного числа и умножаем на другое случайное число для того, чтобы оно не было в диапозоне от 0.0 до 1.0. Про это так же можете почитать в выше упомянутой статье

Можем переходить к написанию тестов

Тестирование — счастливый случай

Будем использовать JSON, так как у нас Rest API

Будем использовать JSON, так как у нас Rest API

Начнем с так называемого «счастливого случая». Напишем функцию «TestMath_HappyPath (t *testing.T)»:

func TestMath_HappyPath(t *testing.T) {

Эта функция будет проверять программу на правильных входных данных

Напишем тесткейсы для нашей программы:

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:   "/",
		},
	}

Тут мы создаем срез тесткейсов, которые состоят из названия и операции, которую будем проводить

Получим наш «suite»:

st := suite.New(t)

Теперь пройдемся циклом по тесткейсам:

for _, tc := range cases {

В цикле вызовем метод «Run»:

t.Run(tc.Name, func(t *testing.T) {

Этот метод запускает тест с нужным нам названием, которое вписано в каждом тесткейсе

В функции, которую передаем в аргументах разрешаем запускать тесты параллельно:

t.Parallel()

Делаем http-запрос к нашей программе:

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

Тут мы создаем буфер, в котором будем хранить json и делаем post-запрос к нашей программе с header равным «application/json», то есть говорим, что передаем json

Используем пакет «testify» и проверям ответ на отсутствие ошибок:

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

Немного про «testify»

При использовании testify Вы будете использовать 2 модуля: «require» и «assert». Их отличия в том, что, например, если при вызове require.NoError () ошибка все-таки будет, то он просто закончит текущий тест, в отличии от assert, который вернет boolean

Продолжаем

Скажем, что бы в конце текущего теста тело ответа было закрыто:

defer resp.Body.Close()

Читаем ответ и проверяем на отсутствие ошибок при чтении:

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

Объявляем переменную «result»:

var result Result

Превращаем тело ответа из json в структуру «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)
}

Здесь же используем «assert»

Вот весь файл «math_test.go»:

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(t)

	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))
}

Тестирование — ошибки

407f8b5873d4d5ea9de1887bfeecea0d.jpg

Мы протестировали «счастливые случаи», но правильнее тестировать неудачи и ошибки. Давайте этим и займемся!

Напишем функцию «TestMath_FailCases (t *testing.T)»:

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(t)

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)

C этого момента идут отличия

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

Мы не делаем проверку на соответствие статусу OK. Мы делаем проверку на ожидаемый код. В наших тестах он только 1 — StatusBadRequest, но во многих программах они отличаются, поэтому мы и прописывали их в тесткейсах

Вот так теперь выглядит код файла «math_test.go»:

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(t)

	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(t)

	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))
}

Тестирование

Давайте проверим нашу программу. Для этого запускаем наше приложение с помощью команды «go run cmd/web/*.go». После этого в другой консоли зайдем в папку tests/ и запустим команду «go test -v». Если Ваш вывод совпадает с моим, то поздравляю, Вы все правильно написали, если нет — сверьтесь с моими тестами.

Вывод:

PASS ok functional-testing/tests 0.010s

Теперь можете попробовать написать эти же тесты, но сами, для практики.

Заключение

Я немало времени потратил на эту статью и надеюсь, что Вы поняли, как писать функциональные тесты и будете их использовать в своих программах!

© Habrahabr.ru