mock-objects, fuzzing and property-based testing

mock-objects, fuzzing and property-based testing

Hello, Habre!

Golang like YA very much good for developing high-performance applications. There comes a point in any project when you need to check how well things actually work, and this can be done through testing.

Testing in Go can be done with mock objects, fuzzing and property-based testing. In this article, we will consider these mechanisms.

Mock objects

Mock objects are such false objects, used in testing to simulate the behavior of real system components. They provide an opportunity to check how the tested component interacts with external dependencies without getting involved in complex relationships with the real world. Simply put, it’s kind of stand-in.

With the use of mock-objects, the architecture of the separable code is also implemented. For example, this is how it works for me: when I design my components with testing in mind (and yes, this is an integral part of development), I start automatically reducing the coupling between different parts of the system. Each component becomes more independent and easier to test, modify or even replace.

Mocking in Go is usually achieved using a library testify/mock.

Suppose there is an interface DoerWhat makes something useful:

type Doer interface {
    DoSomething(int) string
}

And we want to mock this interface for testing. It will look simple:

type MockDoer struct {
    mock.Mock
}

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

Or instead of sending real emails during testing, you can use a mock object:

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", "[email protected]", "Subject", "Body").Return(nil)
    mockMailer.AssertExpectations
}

Suppose there is an external service for weather WeatherService:

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

Mocking this interface for tests:

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

Using MockWeatherService in tests:

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

// mockService

If there is an HTTP controller that depends on Maileryou can mock this dependency in tests:

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}
    // тест методов контроллера
}

Mocking situations when an external system returns an error:

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

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

Fuzzing

Fuzzingor phasing, is the process of automatic testing by presenting unpredictable or random data to the program input. To put it even more simply, it’s like throwing everything in a row, hoping to cause a crash.

Fuzzing helps detect vulnerabilities that standard tests might not indicate, including buffer overflows, memory leaks, exception handling, and more.

In many industries, fuzzing is part of the software requirements.

As of Go 1.18, you can simply write a fuzz test by specifying the function you want to test, and Go will do the rest by generating random data to look for potential bugs.

Let’s say there is a function that parses a URL:

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

The fuzzing function will look like this:

//+build gofuzz

package mypackage

import "testing"

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

After the fuzzing function is written, you need to assemble the fuzzing body and run fuzzing:

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

These commands will start the fuzzing process.

Let’s create a more concrete fuzzing usage scenario in Go to better understand how you can derive information about errors or vulnerabilities from a fuzz test. Let’s say we’re testing a URL parsing feature that might be vulnerable to some specific input data.

Suppose there is the following function ParseURLwhich parses the URL string and returns a structure URL or an error if the URL cannot be resolved:

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
}

Let’s write a fuzz test for this function:

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

After running a fuzz test, go-fuzz can detect inputs that cause unexpected behavior or crashes. Let’s say the fuzz test found some error-causing input that wasn’t properly handled in our code:

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)

Such a conclusion indicates that the function ParseURL failed to process incoming data "http://%00/"which caused the crash.

Property-based testing

WITH property-based testing it is possible to test whether a function satisfies certain properties for a wide range of input data.

Property-based testing generates inputs automatically to test general properties of a function, such as idempotency, commutativity, or invariance among many other possible inputs.

Suppose there is an addition function add(a, b). One of the properties we want to test is this commutativityi.e. add(a, b) == add(b, a) for any a and b.

You can use the library 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
}

Automatically generate random values ​​for a and b and check whether the function satisfies add properties of commutativity.

Idemopotency is a property of an object or operation that guarantees that repeated application will not change the result after the first application. For example, a function that removes all occurrences of a given element from a list must be idempotent.

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
}

Reversibility is a property in which every operation has a reverse operation that returns the system to its initial state. Suppose there is an encryption function and a corresponding decryption function:

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
}

Suppose we have a function that filters a list of numbers, removing anything less than a given threshold and we want to make sure in that, that the length of the result does not exceed the length of the original list:

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
}

Finally, I invite you to join the open class and observe the interview process for the position of Golang Developer Middle. The interviewer will be the head of the Golang Developer course. Professional Oleg Wenger, tech-lead at Avito. After the webinar, it will be easier for you to prepare for a real interview for similar positions.

Related posts