Табличные и индивидуальные субтесты в Golang: какой из них использовать


Введение

Юнит-тесты являются неотъемлемой частью любой кодовой базы, в том числе и той, что находится на моем рабочем месте. Когда инженер добавляет часть новой логики в кодовую базу, от него ожидают, что он добавит модульные тесты, которые будут покрывать, по крайней мере, наиболее распространенные сценарии использования новой логики. Возможно, с парой крайних случаев в качестве вишенки на вершине.

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

Тестирование упаковки коробки с пончиками

Мы рассмотрим два различных способа написания модульных тестов в Golang. Мы будем использовать игривый пример упаковки коробки с пончиками. Вкусы пончиков в примерах вдохновлены пончиками Crosstown. Давайте посмотрим на код, для которого мы будем писать тесты.

Реализация коробки с пончиками

package doughnuts_box

import (
    "fmt"
)

type doughnutsBox struct {
    capacity  int
    doughnuts []string
}

var knownDoughnutTypes = map[string]bool{
    "Matcha Tea":                true,
    "Lime & Coconut (ve)":       true,
    "Home Made Raspberry Jam":   true,
    "Cinnamon Scroll (ve)":      true,
    "Sri Lankan Cinnamon Sugar": true,
}

func newDoughnutsBox(capacity int) *doughnutsBox {
    return &doughnutsBox{
        capacity:  capacity,
        doughnuts: make([]string, 0),
    }
}

func (b *doughnutsBox) pack(doughnuts []string) (int, error) {
    unrecognizedItems := make([]string, 0)
    var err error

    if len(doughnuts) > b.capacity {
        return 0, fmt.Errorf("failed to put %d doughnuts in the box, it's only has %d doughnuts capacity", len(doughnuts), b.capacity)
    }

    for _, doughnut := range doughnuts {
        if _, found := knownDoughnutTypes[doughnut]; found {
            b.doughnuts = append(b.doughnuts, doughnut)
            continue
        }
        unrecognizedItems = append(unrecognizedItems, doughnut)
    }

    if len(unrecognizedItems) > 0 {
        err = fmt.Errorf("the following items cannot be placed into the box: %v", unrecognizedItems)
    }
    return len(b.doughnuts), err
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь, после того как мы ознакомились с реализацией коробки с пончиками, давайте обсудим, как мы можем ее протестировать.

Тестирование упаковки коробки с пончиками с помощью тестов, управляемых таблицами

Тесты, управляемые таблицами, — это распространенный способ написания модульных тестов в Golang. Вы можете встретить этот стиль повсюду.

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

Тесты в виде таблиц хороши в ситуациях, когда предполагается протестировать большое количество сценариев, с четкими ожиданиями
вход x производит выход y.

Для нашего примера с коробкой для пончиков мы написали набор сценариев в виде анонимной структуры testCases. Затем мы запустили их все как подтесты с помощью метода t.Run, внутри знакомого цикла for loop..

Реализация тестов, управляемых таблицами в коробке с пончиками

package doughnuts_box

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestPackDoughnutsBoxTableTests(t *testing.T) {
    // Anonymous struct of test cases
    tests := []struct {
        name                           string
        boxCapacity                    int
        errorExpected                  bool
        errorMessage                   string
        items                          []string
        expectedNumOfDoughnutsInTheBox int
    }{
        {
            name:                           "Filling the box with tasty doughnuts",
            boxCapacity:                    4,
            errorExpected:                  false,
            errorMessage:                   "",
            items:                          []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)"},
            expectedNumOfDoughnutsInTheBox: 4,
        },
        {
            name:                           "Attempt to fill the box with too many doughnuts",
            boxCapacity:                    4,
            errorExpected:                  true,
            errorMessage:                   "failed to put 5 doughnuts in the box, it's only has 4 doughnuts capacity",
            items:                          []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)", "Lime & Coconut (ve)"},
            expectedNumOfDoughnutsInTheBox: 0,
        },
        {
            name:                           "Attempt to put a giant chocolate cookie into the box",
            boxCapacity:                    2,
            errorExpected:                  true,
            errorMessage:                   "the following items cannot be placed into the box: [Giant Chocolate Cookie]",
            items:                          []string{"Sri Lankan Cinnamon Sugar", "Giant Chocolate Cookie"},
            expectedNumOfDoughnutsInTheBox: 1,
        },
    }

    for _, tc := range tests {
        // each test case from  table above run as a subtest
        t.Run(tc.name, func(t *testing.T) {
            // Arrange
            box := newDoughnutsBox(tc.boxCapacity)

            // Act
            numOfDoughnutsInTheBox, err := box.pack(tc.items)

            // Assert
            if tc.errorExpected {
                require.Error(t, err)
                assert.Equal(t, tc.errorMessage, err.Error())
            }
            assert.Equal(t, tc.expectedNumOfDoughnutsInTheBox, numOfDoughnutsInTheBox)
        })
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы рассмотрели тест «счастливого пути» и два теста для ошибок, которые, как мы ожидаем, будут встречаться часто — попытка положить слишком много пончиков в коробку и попытка положить в коробку что-то, что не является пончиком.

Далее мы рассмотрим, как можно переписать те же самые тесты в другом стиле.

Тестирование упаковки пончиков в коробку с помощью отдельных подтестов

Написание сценариев тестов в виде отдельных подтестов менее распространено в Golang. Этот факт, на мой взгляд, вызывает сожаление. Но все же примеров такого синтаксиса достаточно.

Синтаксис отдельных подтестов, в отличие от табличных тестов, благоприятствует читабельности и общей «сухости кода». Тесты могут выглядеть более повторяющимися, но каждый отдельный подтест представляет собой отдельную «историю». Поэтому при использовании отдельных подтестов легче следовать принципам тестирования, ориентированного на поведение, чем при использовании тестов, ориентированных на таблицы.

В нашем небольшом примере все настройки выполняются внутри отдельных подтестов. В более сложных примерах состояние, разделяемое между подтестами, может быть инициализировано в теле родительского теста. А повторяющиеся действия по установке/разборке можно вынести в вспомогательные функции.

Реализация теста Donuts box в виде отдельных подтестов

package doughnuts_box

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestPackDoughnutsBoxSubtests(t *testing.T) {
    t.Run("It fills the box with tasty doughnuts", func(t *testing.T) {
        // Arrange
        items := []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)"}
        box := newDoughnutsBox(4)

        // Act
        numOfDoughnutsInTheBox, err := box.pack(items)

        // Assert
        require.NoError(t, err)
        assert.Equal(t, numOfDoughnutsInTheBox, 4)
    })
    t.Run("It fails to fill the box with too many doughnuts", func(t *testing.T) {
        // Arrange
        items := []string{"Sri Lankan Cinnamon Sugar", "Matcha Tea", "Home Made Raspberry Jam", "Lime & Coconut (ve)", "Lime & Coconut (ve)"}
        box := newDoughnutsBox(4)

        // Act
        numOfDoughnutsInTheBox, err := box.pack(items)

        // Assert
        require.Error(t, err)
        assert.Equal(t, "failed to put 5 doughnuts in the box, it's only has 4 doughnuts capacity", err.Error())
        assert.Equal(t, 0, numOfDoughnutsInTheBox)
    })
    t.Run("It fails to put a giant chocolate cookie into the box", func(t *testing.T) {
        // Arrange
        items := []string{"Sri Lankan Cinnamon Sugar", "Giant Chocolate Cookie"}
        box := newDoughnutsBox(4)

        // Act
        numOfDoughnutsInTheBox, err := box.pack(items)

        // Assert
        require.Error(t, err)
        assert.Equal(t, "the following items cannot be placed into the box: [Giant Chocolate Cookie]", err.Error())
        assert.Equal(t, 1, numOfDoughnutsInTheBox)
    })
}
Вход в полноэкранный режим Выход из полноэкранного режима

Те же три тестовых случая — счастливый путь и два распространенных сценария ошибок — мы написали в виде отдельных подтестов. В нашем примере они даже заняли примерно одинаковое количество строк кода. 58 строк для отдельных подтестов, по сравнению с 63 строками в примере с табличными тестами.

Выводы

Теперь, когда представлены примеры двух стилей для юнит-тестов, пришло время решить, какой из них использовать и когда.

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

Надеюсь, диаграмма поможет. Если у вас есть мнение, что один синтаксис лучше другого, или у вас есть свой собственный способ написания юнит-тестов на Golang, пожалуйста, поделитесь им в комментариях!

Оцените статью
devanswers.ru
Добавить комментарий