Многие пишут юнит-тесты, но не все знают, как писать функциональные. В этой статье будут библиотеки, фишки про функциональные тесты, а самое главное - попрактикуемся их писать на примере 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/tests 0.010s
Теперь можете попробовать написать эти же тесты, но сами, для практики.
Заключение
Я немало времени потратил на эту статью и надеюсь, что Вы поняли, как писать функциональные тесты и будете их использовать в своих программах!
Автор: mo0Oonnn