Привет, Хабр!

Golang как ЯП очень хорош для разработки высокопроизводительных приложений. В любом проекте наступает момент, когда нужно проверить, насколько хорошо все работает на самом деле, это можно сделать с помощью тестирования.

Тестирование в Go можно выполнять с помощью mock-объектов, fuzzing и property-based testing. В этой статье мы рассмотрим эти механизмы.

Mock-объекты

Mock-объекты — это такие подставные объекты, используемые в тестировании для имитации поведения реальных компонентов системы. Они дают возможность проверить, как тестируемый компонент взаимодействует с внешними зависимостями, не ввязываясь в сложные отношения с реальным миром. Проще говоря, это своего рода стенд-ин.

С использованием mock-объектов также реализуется разделяемая архитектура кода. Например, у меня это работает так: когда я проектирую свои компоненты с мыслью о тестировании (и да, это неотъемлемая часть разработки), я начинаю автоматом уменьшать связность между различными частями системы. Каждый компонент становится более независимым и его легче тестировать, модифицировать или даже заменять.

Мокирование в Go обычно достигается с помощью библиотеки testify/mock.

Допустим, есть интерфейс Doer, который делает что-то полезное:

type Doer interface {
    DoSomething(int) string
}

И мы хотим мокировать этот интерфейс для тестирования. Выглядеть это будет простым образом:

type MockDoer struct {
    mock.Mock
}

func (m *MockDoer) DoSomething(number int) string {
    args := m.Called(number)
    return args.String(0)
}

Или вместо того, чтобы отправлять реальные email при тестировании, можно юзать mock-объект:

import (
    "testing"
    "github.com/stretchr/testify/mock"
)

type MockMailer struct {
    mock.Mock
}

func (m *MockMailer) Send(to, subject, body string) error {
    args := m.Called(to, subject, body)
    return args.Error(0)
}

func TestSendEmail(t *testing.T) {
    mockMailer := new(MockMailer)
    mockMailer.On("Send", "example@example.com", "Subject", "Body").Return(nil)
    mockMailer.AssertExpectations(t)
}

Предположим, есть внешний сервис для погоды WeatherService:

type WeatherService interface {
    GetWeather(city string) (float64, error)
}

Мокирование этого интерфейса для тестов:

type MockWeatherService struct {
    mock.Mock
}

func (m *MockWeatherService) GetWeather(city string) (float64, error) {
    args := m.Called(city)
    return args.Get(0).(float64), args.Error(1)
}

Использование MockWeatherService в тестах:

mockService := new(MockWeatherService)
mockService.On("GetWeather", "Moscow").Return(20.0, nil)

// mockService

Если есть HTTP-контроллер, который зависит от Mailer, можно мокировать эту зависимость в тестах:

type Controller struct {
    Mailer Mailer
}

func TestController_SendEmail(t *testing.T) {
    mockMailer := new(MockMailer)
    mockMailer.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil)
    
    controller := Controller{Mailer: mockMailer}
    // тест методов контроллера
}

Мокирование ситуаций, когда внешняя система возвращает ошибку:

mockMailer := new(MockMailer)
mockMailer.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("failed to send"))

// обработка ошибок

Fuzzing

Fuzzing, или фаззинг, – это процесс автоматического тестирования путем подачи непредсказуемых или случайных данных на вход программы. Если сказать еще проще, то это словно бросать всё подряд, надеясь вызвать сбой.

Fuzzing помогает выявить уязвимости, на которые стандартные тесты могут не указывать, включая переполнения буфера, утечки памяти, обработку исключений и многое другое.

Во многих отраслях fuzzing является частью требований к ПО.

С версии Go 1.18 можно просто написать fuzz-тест, указав функцию, которую нужно протестировать, и Go сделает остальное, генерируя случайные данные для поиска потенциальных ошибок.

Допустим, есть функция, которая парсит URL:

func ParseURL(url string) (*URL, error) {
    // логика
}

Fuzzing функция будет выглядеть таким образом:

//+build gofuzz

package mypackage

import "testing"

func FuzzParseURL(f *testing.F) {
    f.Fuzz(func(t *testing.T, data []byte) {
        _ = ParseURL(string(data))
    })
}

После того как fuzzing функция написана, нужно собрать fuzzing корпус и запустить fuzzing:

go get -u github.com/dvyukov/go-fuzz/go-fuzz
go get -u github.com/dvyukov/go-fuzz/go-fuzz-build

Эти команды запустят процесс fuzzing.

Создадим более конкретный сценарий использования fuzzing в Go, чтобы лучше понять, как можно вывести информацию об ошибках или уязвимостях из fuzz-теста. Предположим, что мы тестируем функцию разбора URL, которая может быть уязвима к некоторым специфическим входным данным.

Представим, чтоесть следующая функция ParseURL, которая разбирает строку URL и возвращает структуру URL или ошибку, если URL не может быть разобран:

package urlparser

import (
    "net/url"
    "errors"
)

func ParseURL(input string) (*url.URL, error) {
    parsedURL, err := url.Parse(input)
    if err != nil {
        return nil, err
    }

    if parsedURL.Scheme == "" || parsedURL.Host == "" {
        return nil, errors.New("url lacks scheme or host")
    }

    return parsedURL, nil
}

Напишем fuzz-тест для этой функции:

//+build gofuzz

package urlparser

import "testing"

func FuzzParseURL(f *testing.F) {
    testcases := []string{"http://example.com", "https://example.com", "ftp://example.com"}
    for _, tc := range testcases {
        f.Add(tc) // добавляем начальные тестовые случаи
    }

    f.Fuzz(func(t *testing.T, url string) {
        _, err := ParseURL(url)
        if err != nil {
            t.Fatalf("ParseURL failed for %s: %v", url, err)
        }
    })
}

После запуска fuzz-теста go-fuzz может обнаружить входные данные, которые вызывают неожиданное поведение или сбои. Предположим, fuzz-тест нашел входные данные, вызывающие ошибку, которая не была должным образом обработана в нашем код:

fuzz: elapsed: 15s, execs: 1153423 (76894/sec), crashes: 1, restarts: 1/10000, coverage: 1023/2000 edges
fuzz: minimizing crash input...
fuzz: crash: ParseURL("http://%00/")
fuzz: minimizing crash input...
fuzz: crash reproduced; minimizing...
fuzz: minimized input to 10 bytes (from 28)
fuzz: minimizing duration...
fuzz: duration minimized, 0.1s (from 0.3s)

Такой вывод указывает на то, что функция ParseURL не справилась с обработкой входных данных "http://%00/", что привело к сбою.

Property-based testing

С property-based testing можно, проверять удовлетворяет ли функция определенным свойствам для широкого диапазона входных данных.

Property-based testing генерирует входные данные автоматически, чтобы проверить общие свойства функции, такие как идемпотентность, коммутативность или инвариантность, среди множества других возможных входных данных.

Предположим, есть функция сложения add(a, b). Одно из свойств, которое мы хотим проверить, – это коммутативность, т.е. add(a, b) == add(b, a) для любых a и b.

Можно использовать библиотеку gopter:

package mypackage

import (
    "testing"
    "github.com/leanovate/gopter"
    "github.com/leanovate/gopter/prop"
    "github.com/leanovate/gopter/gen"
)

func TestAddCommutativeProperty(t *testing.T) {
    parameters := gopter.DefaultTestParameters()
    properties := gopter.NewProperties(parameters)

    properties.Property("add is commutative", prop.ForAll(
        func(a int, b int) bool {
            return add(a, b) == add(b, a)
        },
        gen.Int(),
        gen.Int(),
    ))

    properties.TestingRun(t)
}

Автоматически генерим случайные значения для a и b и проверяем, удовлетворяет ли функция add свойству коммутативности.

Идемпотентность – это свойство объекта или операции, которое гарантирует, что повторное применение не изменит результат после первого применения. Например, функция, которая удаляет все вхождения заданного элемента из списка, должна быть идемпотентной.

func removeElement(slice []int, element int) []int {
    var result []int
    for _, v := range slice {
        if v != element {
            result = append(result, v)
        }
    }
    return result
}

func TestRemoveElementIdempotentProperty(t *testing.T) {
    parameters := gopter.DefaultTestParameters()
    properties := gopter.NewProperties(parameters)

    properties.Property("removeElement is idempotent", prop.ForAll(
        func(slice []int, element int) bool {
            firstApplication := removeElement(slice, element)
            secondApplication := removeElement(firstApplication, element)
            return reflect.DeepEqual(firstApplication, secondApplication)
        },
        gen.SliceOf(gen.Int()),
        gen.Int(),
    ))

    properties.TestingRun(t)
}

Обратимость – это свойство, при котором для каждой операции существует обратная операция, возвращающая систему в исходное состояние. Допустим, есть функция шифрования и соответствующая функция дешифрования:

func encrypt(plaintext string, key int) string {
    // простое шифрование путем сдвига каждого символа на key позиций
    result := ""
    for _, char := range plaintext {
        shiftedChar := rune(char + key)
        result += string(shiftedChar)
    }
    return result
}

func decrypt(ciphertext string, key int) string {
    // обратное шифрование
    return encrypt(ciphertext, -key)
}

func TestEncryptionReversibilityProperty(t *testing.T) {
    parameters := gopter.DefaultTestParameters()
    properties := gopter.NewProperties(parameters)

    properties.Property("encrypt and decrypt are reversible", prop.ForAll(
        func(plaintext string, key int) bool {
            ciphertext := encrypt(plaintext, key)
            decryptedText := decrypt(ciphertext, key)
            return plaintext == decryptedText
        },
        gen.AlphaString(), // генерируем строку из алфавитных символов
        gen.IntRange(1, 26), // генерируем ключ шифрования как целое число от 1 до 26
    ))

    properties.TestingRun(t)
}

Предположим, что есть функция, которая фильтрует список чисел, удаляя все, что меньше заданного порога и нам нужно убедиться в том, что длина результата не превышает длину исходного списка:

func filterSlice(slice []int, threshold int) []int {
    var result []int
    for _, v := range slice {
        if v >= threshold {
            result = append(result, v)
        }
    }
    return result
}

func TestFilterSliceLengthProperty(t *testing.T) {
    parameters := gopter.DefaultTestParameters()
    properties := gopter.NewProperties(parameters)

    properties.Property("filterSlice does not increase slice length", prop.ForAll(
        func(slice []int, threshold int) bool {
            result := filterSlice(slice, threshold)
            return len(result) <= len(slice)
        },
        gen.SliceOf(gen.Int()),
        gen.Int(),
    ))

    properties.TestingRun(t)
}

Напоследок приглашаю присоединиться к открытому уроку и понаблюдать за процессом собеседования на позицию Golang Developer Middle. Интервьюером выступит руководитель курса Golang Developer. Professional Олег Венгер, tech-lead в Авито. После вебинара вам будет намного проще подготовиться к реальному собеседованию на аналогичные позиции.

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