Не все знают, как писать функциональные тесты. В этой статье будут библиотеки, фишки про функциональные тесты, а самое главное - попрактикуемся их писать на примере Rest API
Функциональное тестирование
Функциональное тестирование - это такой тип тестирования, когда проверяется не маленькая часть, а вся программа, при этом сама программа не знает о том, что ее тестируюют. Правильно ли она работает при определенных условиях, что вернет, какая будет ошибка и т.д
Библиотеки
Вот несколько библиотек для функционального тестирования:
Пишем тесты
Теперь попробуем написать функциональные тесты. Я подготовил файлы с 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
Теперь можете попробовать написать эти же тесты, но сами, для практики.
Моки
Под конец хочу рассказать про моки. Если Ваша программа работает с какими-нибудь базами данных, то придется использовать моки. Что такое моки? Это подмена реальных функций и объектов на искусственные, имитируя настоящие, чтобы не затрагивать и не обращаться к БД
Чтобы понять, как их писать, есть хорошее видео на ютубе: https://www.youtube.com/watch?v=qaaa3RsC0FQ
Насчет библиотеки, я пользуюсь mockery: github.com/vektra/mockery, но Вы можете использовать любую удобную Вам библиотеку
Заключение
Я немало времени потратил на эту статью и надеюсь, что Вы поняли, как писать функциональные тесты и будете их использовать в своих программах! Это дело не сложнее юнит тестов
Комментарии (6)
skvorec_9
17.08.2024 09:07+1Пока что у нас есть только 1: “local.yaml”, необходимо создать второй: “local_tests.yaml”. В нем оставим все те же настройки, кроме одной - timeout. Изменим ее значение с 4s на 10h
Уместно ли такое утверждение для автотестов?
Если подобное работает локально и не расписанию (в CI/CD по шедулеру), то я еще понимаю такое растягивание таймаутов, но при использовании подобного в прогонах не аффектим ли мы общую статистику выполнения автотестов?
А если говорить про кейсы то деление на 0 наверное то что хотелось бы увидеть (Не знаю умеет ли go в генерацию 0.0 функциейgenerateRandomFloat
)mo0Oonnn Автор
17.08.2024 09:07А если говорить про кейсы то деление на 0 наверное то что хотелось бы увидеть (Не знаю умеет ли go в генерацию 0.0 функцией
generateRandomFloat
)А вот этот случай я не просчитал) Да, если запустить программу и поделить на ноль, то ошибка возникнет но не так, как нам нужно(не будет json-ответа с ошибкой). Спасибо, что учли
Что насчет автотестов - не знаю, скорее всего нет, не уместно. Я писал при условии, что данное тестирование будет проводиться только при запуске программы. Почему мы должны портить статистику выполнения?
skvorec_9
17.08.2024 09:07+1Ну по моему опыту написанию автотестов я вам точно скажу что такой подход bad practice) Лучше делать явные ожидания, и читабельность выше и производительность хорошая
mo0Oonnn Автор
17.08.2024 09:07Изменим ее значение с 4s на 10h(но при автотестировании такого лучше не делать).
Немного исправил
GospodinKolhoznik
Это как сказать, что многие водят автомобили чёрного цвета, но не все знают, как управлять синими автомобилями.
Функциональное тестирование проверяет внешний слой программы - её интерфейс взаимодейстия. Юнит тесты проверяют внутренние слои программы так, как если бы они являлись внешним интерфейсом. Т.е. юнит тест это и есть функциональный тест, который пишется для внуреннего слоя.
mo0Oonnn Автор
Понятное дело, что эти вещи схожи. Но аналогию про автомобили черного и синего цвета я считаю не уместной. При юнит-тестировании Вы вызываете функцию и проверяете ее на правильность. Функциональное тестирование - когда программа, не зная, что ее тестируют, выдает результаты, которые она бы выдала при реальном использовании. Для того, чтобы понять, каким образом это вообще работает и написана эта статья.