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

Функциональное тестирование — это такой тип тестирования, когда проверяется не маленькая часть, а вся программа, при этом сама программа не знает о том, что ее тестируюют. Правильно ли она работает при определенных условиях, что вернет, какая будет ошибка и т.д
Моки
Если Ваша программа работает с какими-нибудь базами данных, то придется использовать моки. Что такое моки? Это подмена реальных функций и объектов на искусственные, имитируя настоящие, чтобы не затрагивать и не обращаться к БД
Чтобы понять, как их писать, есть хорошее видео на ютубе: https://www.youtube.com/watch?v=qaaa3RsC0FQ
Насчет библиотеки, я пользуюсь mockery: github.com/vektra/mockery, но Вы можете использовать любую удобную Вам библиотеку
Библиотеки
Вот несколько библиотек для функционального тестирования:
Пишем тесты
Теперь попробуем написать функциональные тесты. Я подготовил файлы с 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. Про это так же можете почитать в выше упомянутой статье
Можем переходить к написанию тестов
Тестирование — счастливый случай
Начнем с так называемого “счастливого случая”. Напишем функцию “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/tests0.010s
Теперь можете попробовать написать эти же тесты, но сами, для практики.
Заключение
Я немало времени потратил на эту статью и надеюсь, что Вы поняли, как писать функциональные тесты и будете их использовать в своих программах!
ссылка на оригинал статьи https://habr.com/ru/articles/836664/
Добавить комментарий