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

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

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

Библиотеки

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

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

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

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

Напишем функцию “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

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

Моки

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

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

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

Заключение

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

Комментарии (6)


  1. GospodinKolhoznik
    17.08.2024 09:07

    Многие пишут юнит-тесты, но не все знают, как писать функциональные.

    Это как сказать, что многие водят автомобили чёрного цвета, но не все знают, как управлять синими автомобилями.

    Функциональное тестирование проверяет внешний слой программы - её интерфейс взаимодейстия. Юнит тесты проверяют внутренние слои программы так, как если бы они являлись внешним интерфейсом. Т.е. юнит тест это и есть функциональный тест, который пишется для внуреннего слоя.


    1. mo0Oonnn Автор
      17.08.2024 09:07

      Понятное дело, что эти вещи схожи. Но аналогию про автомобили черного и синего цвета я считаю не уместной. При юнит-тестировании Вы вызываете функцию и проверяете ее на правильность. Функциональное тестирование - когда программа, не зная, что ее тестируют, выдает результаты, которые она бы выдала при реальном использовании. Для того, чтобы понять, каким образом это вообще работает и написана эта статья.


  1. skvorec_9
    17.08.2024 09:07
    +1

    Пока что у нас есть только 1: “local.yaml”, необходимо создать второй: “local_tests.yaml”. В нем оставим все те же настройки, кроме одной - timeout. Изменим ее значение с 4s на 10h

    Уместно ли такое утверждение для автотестов?
    Если подобное работает локально и не расписанию (в CI/CD по шедулеру), то я еще понимаю такое растягивание таймаутов, но при использовании подобного в прогонах не аффектим ли мы общую статистику выполнения автотестов?

    А если говорить про кейсы то деление на 0 наверное то что хотелось бы увидеть (Не знаю умеет ли go в генерацию 0.0 функцией generateRandomFloat)


    1. mo0Oonnn Автор
      17.08.2024 09:07

      А если говорить про кейсы то деление на 0 наверное то что хотелось бы увидеть (Не знаю умеет ли go в генерацию 0.0 функцией generateRandomFloat)

      А вот этот случай я не просчитал) Да, если запустить программу и поделить на ноль, то ошибка возникнет но не так, как нам нужно(не будет json-ответа с ошибкой). Спасибо, что учли

      Что насчет автотестов - не знаю, скорее всего нет, не уместно. Я писал при условии, что данное тестирование будет проводиться только при запуске программы. Почему мы должны портить статистику выполнения?


      1. skvorec_9
        17.08.2024 09:07
        +1

        Ну по моему опыту написанию автотестов я вам точно скажу что такой подход bad practice) Лучше делать явные ожидания, и читабельность выше и производительность хорошая


        1. mo0Oonnn Автор
          17.08.2024 09:07

          Изменим ее значение с 4s на 10h(но при автотестировании такого лучше не делать).

          Немного исправил