mock objects, fuzzing and property-based testing

Mock objects

Mock objects – these are such dummy objects, used in testing to simulate the behavior of real system components. They make it possible to check how the component under test interacts with external dependencies without getting involved in complex relationships with the real world. Simply put, it's a kind of stand-in.

Shared code architecture is also implemented using mock objects. 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 automatically begin to reduce the coupling between different parts of the system. Each component becomes more independent and is easier to test, modify, or even replace.

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

Let's say there is an interface Doerwhich does 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 when 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", "example@example.com", "Subject", "Body").Return(nil)
    mockMailer.AssertExpectations
}

Suppose there is an external weather service WeatherService:

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

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

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

Mock situations where the 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 fuzzing, is the process of automatic testing by feeding unpredictable or random data into a program's input. To put it even more simply, it’s like throwing everything in a row, hoping to cause a failure.

Fuzzing helps identify vulnerabilities that standard tests may not identify, 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, generating random data to look for potential bugs.

Let's say there is a function that parses URLs:

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 corpus 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 specific use case for fuzzing in Go to better understand how we can infer information about bugs or vulnerabilities from a fuzz test. Let's say we're testing a URL parsing function that might be vulnerable to some specific input.

Let's imagine that there is the following function ParseURLwhich parses the URL string and returns the structure URL or an error if the URL cannot be parsed:

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 input that caused an error 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("https://%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)

This output indicates that the function ParseURL failed to process input data "http://%00/"which led to the failure.

Property-based testing

WITH property-based testing you can check 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 check is 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 if the function satisfies add property of commutativity.

Idempotency is a property of an object or operation that ensures 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 for each operation there is an inverse operation that returns the system to its original state. Let's say 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 everything that is less than a given threshold and we need to make sure in, 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 open lesson and watch the interview process for the Golang Developer Middle position. The interviewer will be the head of the Golang Developer course. Professional Oleg Venger, tech-lead in Avito. After the webinar, it will be much easier for you to prepare for a real interview for similar positions.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *