Это текстовая версия моего ролика про тесты, который я сделал совместно с Анастасией Заречневой:

Зачем нужно писать тесты?

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

  1. Автоматические тесты можно перезапустить в любой момент, и это займёт меньше времени, чем проверка кода вручную. Особенно это заметно при росте количества тест-кейсов, а их обязательно станет больше. Такие тесты хорошо масштабируются со временем, особенно при использовании параметризации — запуске одного и того же теста множество раз на разных данных.
  2. Минимизация человеческого фактора — человек может забыть или неправильно понять тот или иной шаг в тестовом сценарии.
  3. Автотесты служат своего рода спецификацией — хорошо написанные тесты могут помочь разобраться в том, как работает программа, а так же могут дать знать, если поведение программы больше не соответствует ожиданиям.
  4. Тесты помогают находить ошибки, о наличии которых крайне сложно догадаться при ручном тестировании.

Принципы F.I.R.S.T.

Тесты — это такой же код в свободной форме, как и любой другой. Поэтому, чтобы не порождать в них хаос, нужно также следовать каким-то принципам и архитектуре. Одним из таких наборов принципов является F.I.R.S.T.:

  • Fast (быстрый)
  • Isolated/Independent (изолированный/независимый)
  • Repeatable (воспроизводимый)
  • Self-validating (самопроверяющийся)
  • Thorough (тщательный)

Fast

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

Isolated/Independent

Каждый тест должен быть независимым от всех остальных. В частности, результат теста не должен зависеть от внешних факторов, таких как порядок запуска тестов. Этого легче достичь, если тест структурирован в соответствии с подходом Arrange, Act, Assert:

  1. Arrange. Подготовка окружения и данных. Этот шаг описывает состояние системы перед запуском сценария, который вы тестируете.
  2. Act. Вызов тестируемого кода с данными и в окружении, подготовленными на предыдущем шаге.
  3. Assert. Сравнение результата предыдущего шага с ожидаемым.

Arrange, Act, Assert или 3A также лёг в основу структуры Given, When, Then в разработке по принципу Behaviour Driven Development.

Repeatable

Тесты должны вести себя предсказуемо — если тест упал в первый раз, то он должен падать и в дальнейшем, если в нём не было изменений. Аналогично и в обратную сторону.

Этого проще достичь, если каждый тест работает с собственными набором данных и имеет детерминированное поведение.

Self-validating

Тут всё просто — вы не должны проверять вручную, прошёл тест или нет.

Thorough

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

Пирамида тестирования

Тестирование может проходить на разных уровнях: от отдельных функций до всей системы. От уровня с одной стороны зависит стоимость внедрения тестов, а с другой стороны — степень изоляции тестов. В названии присутствует слово «пирамида» не просто так — чем ниже уровень тестов, тем больше их должно быть, и наоборот.

Придерживайтесь формы пирамиды, чтобы иметь надёжный, быстрый и удобный набор тестов: пишите много небольших и быстрых юнит-тестов. Поверх этого напишите несколько тестов «крупными мазками» и совсем немного тестов уровня End-to-End. Следите за тем, чтобы у вас не получился рожок вместо пирамиды — его обслуживание будет кошмаром, а выполнение таких тестов будет слишком долгим © Martin Fowler.

Пакет testing

Окей, мы разобрались, зачем и как писать тесты в общих чертах. Давайте теперь посмотрим, какие инструменты для тестирования нам доступны в Go.

Во-первых, это, конечно, пакет testing из стандартной библиотеки. Представьте, что у вас есть файл sum.go со следующим содержимым:

1
2
3
4
5
package sum

func Sum(a, b int) int {
	return a + b
}

Давайте напишем тест на функцию Sum. Для этого в той же папке создадим файл sum_test.go. Обратите внимание, постфикс _test в имени файла очень важен.

Код тестов можно связать с тем же пакетом, в котором располагается тестируемый код:

1
package sum

Но можно, и я настоятельно рекомендую в имя пакета так же добавлять постфикс _test:

1
package sum_test

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

Добавим импорт пакета testing, он нам сейчас пригодится:

1
2
3
package sum_test

import "testing"

Тесты в Go — это функции, которые имеют сигнатуру со следующей структурой:

1
2
3
func TestXXX(t *testing.T) {
    // код теста
}
  • XXX в нашем случае нужно заменить на Sum — т.е. это то, что проверяет данный тест. Также важно, чтобы то, что вы подставляете в XXX не начиналось с маленькой буквы.
  • t — это объект-контекст текущего теста, позволяющий нам управлять выполнением теста. В примере ниже есть наглядный пример.

Давайте наконец напишем тест для функции Sum. Вспомним про подход 3A и начнём с шага Arrange:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package sum_test

import "testing"

func TestSum(t *testing.T) {
    // Arrange:
    var (
        a        = 1
        b        = 2
        expected = a + b
    )
}

Здесь a и b являются тестовыми данными и будут использоваться как аргументы функции Sum. На этом же шаге имеет смысл декларировать ожидаемый результат. В итоге эту часть теста можно прочитать вот так:

Ожидается, что на a и b результат (expected) будет равен a + b

Шаг Act очень простой и состоит из одного действия — вызова функции Sum:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package sum_test

import (
    "testing"

    "github.com/johndoe/my-app/sum"
)

func TestSum(t *testing.T) {
    // Arrange:
    var (
        a        = 1
        b        = 2
        expected = a + b
    )

    // Act:
    actual := sum.Sum(a, b)
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package sum_test

import (
    "testing"

    "github.com/johndoe/my-app/sum"
)

func TestSum(t *testing.T) {
    // Arrange:
    var (
        a        = 1
        b        = 2
        expected = a + b
    )

    // Act:
    actual := sum.Sum(a, b)

    // Assert:
    if actual != expected {
        t.Errorf("Expected %d, got %d", expected, actual)
    }
}

В примере Arrange, Act и Assert явно отмечены комментариями для наглядности — вам не нужно этого делать в вашем коде.

Пример. Сокращатель ссылок

Теперь давайте рассмотрим более реалистичный и сложный пример — тесты для сервиса сокращения ссылок. Будем двигаться по пирамиде снизу вверх, т.е. начнём с юнит-тестов. Главная функция во всём сервисе — та, что возвращает короткий идентификатор для ссылки. Выглядеть она может вот так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const alphabet = "ynAJfoSgdXHB5VasEMtcbPCr1uNZ4LG723ehWkvwYR6KpxjTm8iQUFqz9D"

var alphabetLen = uint32(len(alphabet))

func Shorten(id uint32) string {
	var (
		digits  []uint32
		num     = id
		builder strings.Builder
	)

	for num > 0 {
		digits = append(digits, num%alphabetLen)
		num /= alphabetLen
	}

	utils.Reverse(digits)

	for _, digit := range digits {
		builder.WriteString(string(alphabet[digit]))
	}

	return builder.String()
}

Нам сейчас не так важно, что конкретно происходит на каждом шагу — об этом я подробно рассказывал, когда писал сам сервис. При написании тестов мы должны опираться на контракт (ожидания от функции с точки зрения поведения), а не детали реализации.

Сходу понять, как и что тестировать может быть сложно, поэтому для начала определим поведение, которое мы ожидаем от Shorten и, соответственно, хотим верифицировать с помощью тестов:

Shorten
    возвращает идентификатор
        состоящий из символов латинского алфавита и цифр [P1]
    идемпотентна [P2]

Идемпотентность (англ. idempotency) — свойство функции, при котором она на одинаковых входных данных возвращает одинаковый результат.

К счастью в testing есть инструмент, позволяющий писать тесты также в иерархическом стиле — подтесты. Давайте с помощью подтестов накидаем каркас:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package shorten_test

import (
	"testing"

	"github.com/defer-panic/url-shortener-api/internal/shorten"
	"github.com/stretchr/testify/assert"
)

func TestShorten(t *testing.T) {
	t.Run("returns an identifier", func(t *testing.T) {
        t.Run("consisting of latin letters and digits", func(t *testing.T) {})
    })

	t.Run("is idempotent", func(t *testing.T) {})
}

Каждая функция, переданная в t.Run будет исполняться как независимый тест, но иерархически он является частью другого теста. Такой стиль позволяет, например, использовать общие фикстуры для тестов, а также помогает реализовать Behavior Driven Development (BDD).

Фикстуры (англ. fixtures) — данные и объекты, необходимые для выполнения теста. В Go они обычно создаются внутри теста, но иногда бывает удобно вынести их в общую область видимости.

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

IDExpected
1024"Mv"
0""

ID — это входные данные, а Expected — ожидаемый результат. Для начала нам хватит данных двух кейсов. Табличные тесты удобны тем, что в них легко добавлять новые тестовые данные. Также таблицы подходят для тех случаев, когда динамическое формирование тестовых данных является слишком сложной задачей. Минус заключается в том, что мы проверяем функцию в конкретных точках, а не на всей области определения. Об этом мы поговорим чуть позже, а пока давайте начнём заполнять каркас для P1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
t.Run("returns an identifier", func(t *testing.T) {
    t.Run("consisting of latin letters and digits", func(t *testing.T) {
		type testCase struct {
			id       uint32
			expected string
		}

        testCases := []testCase{
            {
                id:        1024,
                expected: "Mv",
            },
            {
                id: 0,
                expected: "",
            },
		}
    })
})

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
t.Run("returns an identifier", func(t *testing.T) {
    t.Run("consisting of latin letters and digits", func(t *testing.T) {
		type testCase struct {
			id       uint32
			expected string
		}

        testCases := []testCase{
            {
                id:        1024,
                expected: "Mv",
            },
            {
                id: 0,
                expected: "",
            },
		}

		for _, tc := range testCases {
			actual := shorten.Shorten(tc.id)
			assert.Equal(t, tc.expected, actual)
		}
    })
})

Здесь для проверки результата используется метод Equal из библиотеки testify. Такая запись легче читается, к тому же testify содержит множество встроенных проверок, в том числе довольно сложных. В testify есть два подпакета: require и assert. Они содержат одинаковый набор ассершенов, разница только в поведении. Если не проходит проверка из assert, это будет отражено в отчёте о тестах, однако выполнение теста продолжится. Непрошедшие проверки из require в свою очередь останавливают выполнение теста.

Кстати, табличные тесты можно запускать параллельно. Для этого достаточно в корневом тесте добавить вызов t.Parallel():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
t.Run("returns an identifier", func(t *testing.T) {
    t.Run("consisting of latin letters and digits", func(t *testing.T) {
        t.Parallel()

        // ...

        for _, tc := range testCases {
            actual := shorten.Shorten(tc.id)
            assert.Equal(t, tc.expected, actual)
        }
    })

})

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

1
2
3
4
5
6
for _, tc := range testCases {
    tc := tc

    actual := shorten.Shorten(tc.id)
    assert.Equal(t, tc.expected, actual)
}

Теперь давайте заполним реализацией кейс P2. Как можно проверить идемпотентность функции? Самый простой способ — вызвать её дважды подряд и сравнить результаты. Если результаты совпадают, значит функция идемпотентна:

1
2
3
4
t.Run("is idempotent", func(t *testing.T) {
    assert.Equal(t, "Mv", shorten.Shorten(1024))
    assert.Equal(t, "Mv", shorten.Shorten(1024))
})

Property based testing

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

В стандартной библиотеке языка есть пакет testing/quick, который помогает при написании property based тестов. Давайте перепишем P2 следующим образом:

1
2
3
4
5
6
7
t.Run("is idempotent", func(t *testing.T) {
    f := func(id uint32) string {
        return shorten.Shorten(id)
    }

    require.NoError(t, quick.CheckEqual(f, f, nil))
})

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

Можем провернуть то же самое и для P1. Начать стоит с описания функции, которая проверяет, что Shorten обладает ожидаемыми свойствами на случайных данных:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func shortIDIsAlphaNumericAndShort(id uint32) bool {
    shortID := shorten.Shorten(id)

    for _, r := range shortID {
        isLetter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
        if !(isLetter || unicode.IsNumber(r)) {
            return false
        }
    }

    return len(shortID) <= 6 && len(shortID) > 0
}

Данная функция проверяет два свойства:

  • Shorten возвращает строку, состоящую только из букв и цифр
  • Shorten возвращает строку длиной не более 6 символов

Теперь перепишем сам тест:

1
2
3
t.Run("returns an alphanumeric short identifier", func(t *testing.T) {
    require.NoError(t, quick.Check(shortIDIsAlphaNumericAndShort, nil))
})

Обратите внимание, что здесь мы используем Check вместо CheckEqual. Мы проверяем не равенство функций, а более комплексные условия. Check принимает на вход функцию, возвращающую true или false.

Тесты и внешний мир

Для получения сокращённой ссылки кроме вызова функции Shorten нужно произвести ещё несколько шагов, среди которых запись ссылки в базу данных. Обычно все такие отдельные части логики соединяются воедино на специальном слое кодовой базы. Этот слой называют сервисным слоем, слоем бизнес-логики или слоем юзкейсов. В случае с сокращателем ссылок сервис может выглядеть примерно так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package shorten

import (
    "context"
    "log"

    "github.com/defer-panic/url-shortener-api/internal/model"
    "github.com/google/uuid"
)

type Storage interface {
    Put(ctx context.Context, shortening model.Shortening) (*model.Shortening, error)
    Get(ctx context.Context, identifier string) (*model.Shortening, error)
    IncrementVisits(ctx context.Context, identifier string) error
}

type Service struct {
    storage Storage
}

func NewService(storage Storage) *Service {
    return &Service{storage: storage}
}

func (s *Service) Shorten(ctx context.Context, input model.ShortenInput) (*model.Shortening, error) {
    var (
        id         = uuid.New().ID()
        identifier = input.Identifier.OrElse(Shorten(id))
    )

    inputShortening := model.Shortening{
        Identifier:  identifier,
        OriginalURL: input.RawURL,
        CreatedBy:   input.CreatedBy,
    }

    shortening, err := s.storage.Put(ctx, inputShortening)
    if err != nil {
        return nil, err
    }

    return shortening, nil
}

func (s *Service) Get(ctx context.Context, identifier string) (*model.Shortening, error) {
    shortening, err := s.storage.Get(ctx, identifier)
    if err != nil {
        return nil, err
    }

    return shortening, nil
}

func (s *Service) Redirect(ctx context.Context, identifier string) (string, error) {
    shortening, err := s.storage.Get(ctx, identifier)
    if err != nil {
        return "", err
    }

    if err := s.storage.IncrementVisits(ctx, identifier); err != nil {
        log.Printf("failed to increment visits for identifier %q: %v", identifier, err)
    }

    return shortening.OriginalURL, nil
}

Подробнее о том, как необходимо выстраивать структуру и архитектуру сервисов на Go мы поговорим отдельно. Сейчас нам важно понимать, что часто у нас появляется необходимость протестировать код, который взаимодействует со внешним миром, будь то операционная система, другие сервисы, очереди и т.д. СУБД — это тоже часть внешнего мира. Если мы будем по-честному обращаться к ней каждый раз при запуске тестов, мы будем нарушать принципы F.I.R.S.T. и усложним себе жизнь, поскольку предыдущие прогоны будут влиять на последующие. Кроме того, результаты запуска тестов теперь будут зависеть от стабильности СУБД и канала связи с ней.

Нам нужен способ, который позволит тестировать бизнес-логику в контроллируемых условиях. Для этой цели можно использовать два разных подхода:

  • Воссоздавать всё необходимое окружение и очищать состояние после прогона тестов. Это отличный способ верификации того, как разные компоненты системы, в том числе внешние, взаимодействуют друг с другом. Тесты, запускаемые в таком режиме называют интеграционными или End to End (E2E) в зависимости от масштаба покрытия. У данного подхода два минуса:

    • Дорого настраивать и поддерживать;
    • Не все компоненты внешнего мира можно затащить в тестовое окружение.
  • Вместо настоящих зависимостей подставлять заглушки, которые обладают тем же интерфейсом. Такие заглушки принято называть моками (англ. mocks). При использовании моков тестовое окружение подготавливать значительно легче, и замокать можно всё что угодно. Однако нельзя быть уверенным, что мок ведёт себя так же, как настоящая зависимость — мок всего лишь соблюдает тот же интерфейс.

В нашем случае мы можем начать с тестов с моками, посколько для нас приоритетнее протестировать бизнес-логику. Моки можно писать руками, и в отдельных специфичных случаях это может быть оправдано. Но чаще всего проще сгенерировать код для моков. Есть разные инструменты для генерации моков, у каждого свои плюсы и минусы. Мы же остановимся на утилите под названием moq.

Для начала необходимо установить moq на свой компьютер:

go install github.com/matryer/moq@latest

У moq два способа использования. Можно вызывать его напрямую:

moq --out=mocks_test.go . MyInterface

Либо через директиву go:generate:

1
2
3
4
5
6
7
package my

//go:generate moq --out=myinterface_moq_test.go . MyInterface
type MyInterface interface {
    Method1() error
    Method2(i int)
}
  • out — имя файла, в который будет записан сгенерированный код

  • . — директория, в которой необходимо искать файл с указанным интерфейсом

  • MyInterface — интерфейс, для которого необходимо сгенерировать мок-реализацию

Давайте сгенерируем мок для интерфейса хранилища. Для этого добавим в начале файла следующую строчку:

1
2
3
4
5
6
package shorten

//go:generate moq --out=mocks/mock_storage.go --pkg=mocks . Storage

import (
///...

Затем можно выполнить следующую команду в корневой директории с сервисом:

go generate ./...

После этого рядом с файлом service.go, в котором реализован сервисный слой и объявлен интерфейс Storage мы увидим папку mocks, а внутри файл mock_storage.go. Его содержимое нас не сильно интересует, поскольу внутри лежит сгенерированный код. Однако теперь мы можем использовать мок в тестах на сервис:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func TestService_Shorten(t *testing.T) {
    t.Run("generates shortening for a given URL", func(t *testing.T) {
        var (
            storage = &mocks.StorageMock{
                PutFunc: func(_ context.Context, shortening model.Shortening) (*model.Shortening, error) {
                    shortening.CreatedAt = time.Now().UTC()
                    return &shortening, nil
                },
            }
            svc   = shorten.NewService(storage)
            input = model.ShortenInput{RawURL: "https://www.google.com"}
        )

        shortening, err := svc.Shorten(context.Background(), input)
        require.NoError(t, err)

        assert.NotEmpty(t, shortening.Identifier)
        assert.Equal(t, "https://www.google.com", shortening.OriginalURL)
        assert.NotZero(t, shortening.CreatedAt)

        assert.Equal(t, 1, len(storage.PutCalls()))
    })

// ...
}

Здесь необходимо обратить внимание на две части кода. Во-первых, посмотрите, как инициализируется мок — для каждого конкретного теста мы можем настроить поведение мока, при этом нет необходимости делать это для всех методов. Во-вторых, у нас появляется возможность проверить, сколько раз и с какими аргументами вызывалась фукнция Put у мока — для этого нужно вызывать метод PutCalls.

TDD. Обзор исследований

Test Driven Development (TDD) или разработка через тестирование — это методология разработки, в которой сначала пишутся тесты, а потом уже тестируемый код. Согласно задумке Кента Бека, автора методологии, это должно форсить разработчиков больше думать о дизайне программы, а также писать более простой код, т.к. такой код легче тестировать. С другой стороны TDD — это навык с высоким порогом входа, требующий хорошей дисциплины при написании кода.

Есть два лагеря: первые утверждают, что TDD — это единственный способ писать действительно надёжное ПО, другие считают, что это лишь карго-культ на грани религиозного фанатизма. В случаях, когда существует такая поляризация мнений, я предпочитаю обращаться к данным и к исследованиям. Вопрос с TDD — не исключение.

About the Return on Investment of Test-Driven Development

Matthias M. Müller, Frank Padberg @ Karlsruher Institut für Technologie, Germany

5 International Workshop on Economic-Driven Software Engineering Research

В данном исследовании авторы построили экономическую модель, чтобы рассчитать ROI (Return of Investment) внедрения TDD.

Главный вывод — TDD скорее приводит к повышению качества* кодовой базы и является экономически обоснованным. Но важно обращать внимание на детали. Во-первых, рентабельность TDD не зависит от размера команды и зарплат разработчиков. Есть небольшое влияние продуктивности самих разработчиков при написании продакшен-кода, но всё-таки наибольший импакт приносит сложность применения TDD в проекте. Она в большей степени зависит от того, на каком этапе была внедрена данная методология: чем раньше, тем меньше сложность. Всё это, конечно, при условии, что мы имеем команду опытных разработчиков.

*Под качеством здесь и далее подразумеваетя метрика, обратно пропорциональная либо плотности дефектов, либо времени, которое уходит на устранение дефектов.

Evaluating the Efficacy of Test-Driven Development: Industrial Case Studies

Thirumalesh Bhat @ Center for Software Excellence, Nachiappan Nagappan @ Microsoft Research

Исследование, проведённое внутри Microsoft гласит, что проекты, в рамках которых практиковалось TDD имеют качество кода выше более чем в два раза по сравнению с проектами без TDD. При этом затраты на разработку возрастают примерно на 15%.

Другой важный тезис, который часто можно встретить и за пределами исследований: тесты при использовании TDD служат спецификацией. Особенно это полезным оказалось для низкоуровневого кода.

An Experimental Evaluation of the Effectiveness and Efficiency of the Test Driven Development

Atul Gupta, Pankaj Jalote @ Dept. of Computer Sc. & Engineering Indian Institute of Technology Kanpur, India

First International Symposium on Empirical Software Engineering and Measurement

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

Factors Limiting Industrial Adoption of Test Driven Development: A Systematic Review

Adnan Causevic, Daniel Sundmark, Sasikumar Punnekkat @ Mälardalen University, School of Innovation, Design and Engineering, Västerås, Sweden

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

  1. Увеличение времени (стоимости) разработки;
  2. Недостаток опыта в применении TDD;
  3. Плохой дизайн системы, который нивелирует преимущества TDD;
  4. Недостаток навыков тестирования у разработчиков;
  5. Неправильное понимание и, как следствие, неверное следование TDD;
  6. Ограничения, связанные с предметной областью или используемыми инструментами;
  7. Легаси-код.

Выводы

TDD — это хорошая методология, но при условии, что в команде все понимают, как применять её на практике. Реальный профит от TDD зависит от особенностей вашего проекта, поэтому не стоит воспринимать данную методологию как «серебряную пулю». Я опредёленно рекомендую хотя бы попробовать пописать код какое-то время с использованием TDD. Если это повысит вашу продуктивность и качество кода, то почему бы и нет?

Если идея писать заведомо красные тесты перед продакшен-кодом вам кажется всё-таки слишком экстремальной, то вы можете воспользоваться следующим трюком: перед тем как написать фукнцию или модуль задайте себе следующие вопросы:

  • Как я буду использвать данный API как пользователь?
  • Какое поведение по умолчанию было бы удобным?
  • Какие возможности для конфигурации и расширения мне понадобятся?

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

Тестовое покрытие

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

Представьте, что мне нужно написать тест на метод, удаляющий пользователя из базы данных. Для этого необходимо, чтобы при удалении пользователь присутствовал в базе. Для этого я вызову в тесте функцию для создания пользователя на первом шаге (Arrange):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestDeleteUser(t *testing.T) {
    user := CreateUser("John", "Doe")
    require.NoError(t, InsertUser(user))

    err := DeleteUser(user.ID)
    assert.Nil(t, err, "Expected user to be deleted, but got an error: %s", err)

    deletedUser, err := FindUser(user.ID)
    assert.NotNil(t, err, "Expected error when finding deleted user")
    assert.Nil(t, deletedUser, "Expected user to be deleted, but found it in the database")
}

В данном случае InsertUser будет считаться покрытой тестами, по крайней мере для позитивного сценария — когда удалось вставить пользователя в базу данных. Формально метод вызывается в тестовом коде, и этого уже достаточно для попадания в покрытие. Но действительно ли InsertUser покрыт тестами? Ну, как видите, теста на данную функцию у нас нет, поэтому считать её протестированной будет некорректно. Поэтому при анализе качества кодовой базы нужно обращать не только на процент покрытия, но и наличие у каждой функции собственного теста. Но и тут есть свои исключения.

Минимально допустимый процент покрытия

Какой показатель покрытия можно считать допустимым? На моей практике встречались разные значения — буквально от 0 до 100. Иногда это была условность, а иногда код нельзя было смёржить, если покрытие падало ниже определённого показателя. Кроме того, я встречал проекты, где всё работало отлично при покрытии 50% и тормозное и багованное нечто при покрытии недалеко от 100%.

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

Инструменты в Go для работы с покрытием

Как и в случае с тестовым фреймворком, вместе с Go уже поставляется инструмент для работы с тестовым покрытием. Он так и называется — cover. Всё что от вас требуется для сбора покрытия — добавить флаг --cover в go test:

$ go test --cover
PASS
coverage: 42.9% of statements
ok      size    0.026s

Просмотр покрытия

Можно сохранить информацию о покрытии, чтобы можно было её просматривать позднее без запуска тестов:

$ go test --coverprofile=coverage.out

Файл coverage.out можно скормить утилите cover:

$ go tool cover --func=coverage.out
size.go:    Size          42.9%
total:      (statements)  42.9%

Будет выведена таблица, которую нужно читать следующим образом:

ФайлФункция%
size.goSize42.9
Итог-42.9

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

$ go tool cover --html=coverage.out

Способы сбора покрытия

В Go доступно три способа покрытия:

  • set: простой бинарный признак, указывающий, выполнялась ли строка или нет
  • count: как много раз выполнялась строка
  • atomic: то же самое, что и count, но работает лучше для параллельных программ

set, count и atomic являются значениями для флага --cover. По умолчанию используется set. Если вы хотите отслеживать не только факт исполнения строки, но и частоту, используйте count. Если к тому же тесты верифицируют параллельный код, используйте atomic.

Полезные ссылки