DevGang
Авторизоваться

Тестирование в Go: повышение эффективности кода

Программирование - задача не из легких, и даже самые опытные разработчики и программисты не в состоянии написать код в том точном и идеальном формате, в котором они хотят или собираются его создать.

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

go test

Тестовая функция в Go начинается с Test и имеет единственный параметр *testing.T. В большинстве случаев вы называете юнит-тест Test[NameOfFunction]. Пакет тестирования предоставляет инструменты для взаимодействия с тестовым процессом, например t.Errorf, который указывает на неудачу теста, выводя на консоль сообщение об ошибке.

Типы тестов

Как правило, в Go существует два типа тестов:

  1. Модульные тесты

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

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

  1. Интеграционные тесты

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

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

Игра с The Testing

Чтобы создать GO-тест, создайте файл с именем math_test.go. Компилятор GO умеет игнорировать код в любых файлах, которые заканчиваются на _test.go, поэтому код, определенный в этом файле, может быть использован только тестом go.

Затем мы импортируем специальный пакет тестирования и определяем функцию, начинающуюся со слова Test, за которым следует то, как мы хотим назвать наш тест. Мы будем тестировать функцию Average, которую мы написали ранее, поэтому назовем ее TestAverage:

package maths

import "testing"

func TestAverage(t *testing.T){
    v := Average([]float64{1, 2})
    if v != 1.5{
        t.Error("Expected 1.5, got ", v)
    }
}

В представленном коде параметр t.Error используется для сообщения о сбое теста в фреймворке тестирования Go. Параметр t имеет тип *testing.T, который представляет собой тестовый контекст фреймворка тестирования, позволяющий управлять результатами тестирования и сообщать о них.

Здесь, в этой функции, мы спрашиваем, является ли среднее значение чисел правильным или нет, и если среднее значение не записывается, что в данном случае равно 1,5 для чисел 1 и 2, то это должно привести к ошибке.

В приведенном вами фрагменте кода функция TestAverage представляет собой модульный тест, который проверяет правильность работы функции Average. Если условие проверки, указанное в операторе if, не выполняется, то есть вычисленное среднее не равно 1,5, тест завершится неудачей, и нужно сообщить об этом.

Чтобы запустить тест, выполните в том же каталоге следующую команду:

go test

При запуске кода, если он прошел тест, в терминале появятся строки, расположенные ниже команды run:

PASS ok golang-program/test 0.32s

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

Команда go test ищет любые тесты в любом из файлов текущей папки и запускает их. Тесты идентифицируются путем запуска функции со словом Test и принятия одного аргумента типа *testing.T.

Аналогичным образом мы можем написать код для проверки среднего значения чисел в программе как:

package math

import "testing"

type testpair struct{
    values []float64
    average float64
}

var tests = []testpair{
    {[]float64{1, 2}, 1.5},
    {[]float64{1, 4}, 2.5},
    {[]float64{5, 8, 10}, 11.5},
    {[]float64{-2, 2}, 0}
}

func TestAverage(t *testing.T){
    for _, pair := range tests{
        v := Average(pair.values)
        if v != pair.average{
            t.Error(
                "For", pair.values,
                "expected", pair.average
                "got", v,
            )
        }
    }
}

Поэтому здесь, в этом блоге, в коде присутствуют определенные функции:

  1. for _, pair := range tests: Это цикл for, основанный на диапазоне. Он выполняет итерацию по каждому элементу фрагмента tests, где каждый элемент представлен переменной pair. Знак подчеркивания _ используется в Go как "пустой идентификатор", чтобы указать, что нам не нужно использовать индекс элемента в цикле. Поскольку тестовые примеры хранятся в виде структур, а нас интересуют только значения этих структур, мы используем _ для игнорирования индекса.
  2. if v != pair.average { ... }: После вычисления среднего значения для текущего тестового случая мы проверяем, равно ли вычисленное среднее (v) ожидаемому среднему (pair.average). Если эти два значения не равны, значит, тест провален, и мы хотим сообщить об ошибке с соответствующей информацией о тестовом случае.
  3. t.Error(" For", pair.values, "expected", pair.average "got", v,): Если тест завершается неудачей (вычисленное среднее не равно ожидаемому среднему), то с помощью t.Error мы сообщаем о неудаче с помощью пользовательского сообщения об ошибке. Сообщение об ошибке содержит такие сведения, как входные значения для тестового случая (pair.values), ожидаемое среднее (pair.average) и вычисленное среднее (v).

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

Это очень распространенный способ создания тестов. Мы создаем структуры для представления входов и выходов функции:

type testpair struct{
    values []float64
    average float64
}

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

Канонический способ написания модульных тестов

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

Это дает два преимущества:

  1. Табличные тесты повторно используют одну и ту же логику утверждений, что позволяет сохранить DRY-систему.
  2. Табличные тесты позволяют легко определить, что входит в тест, поскольку можно легко увидеть, какие входы были выбраны. Кроме того, каждой строке можно присвоить уникальное имя, которое поможет определить, что именно тестируется, и выразить замысел теста.
func TestNumberTableDriven(t *testing.T) {
      // Defining the columns of the table
        var tests = []struct {
        name string
            input int
            want  string
        }{
            // the table itself
            {"1 is the first", 1, "first"},
            {"2 si the second", 2, "second"},
            {"3 is not the first ", 3, "3"},
            {"4 is the fourth", 4, "fourth"},
        }
      // The execution loop
        for _, tt := range tests {
            t.Run(tt.name, func(t *testing.T) {
                ans := Number(tt.input)
                if ans != tt.want {
                    t.Errorf("got %s, want %s", ans, tt.want)
                }
            })
        }
    }

В конечном итоге этот код даст такой результат:

--- PASS: TestNumberTableDriven (0.00s) 
--- PASS: TestNumberTableDriven/1_is_the_first (0.00s) 
--- PASS: TestNumberTableDriven/2_si_the_second (0.00s) 
--- PASS: TestNumberTableDriven/3_is_not_the_first (0.00s) 
--- PASS: TestNumberTableDriven/4_is_the_fourth (0.00s) PASS

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

В цикле выполнения вызывается t.Run(), который определяет подтест. В результате каждая строка таблицы определяет подтест с именем [NameOfTheFuction]/[NameOfTheSubTest].

Этот способ написания тестов очень популярен и считается каноническим способом написания модульных тестов в Go.

Пакет тестирования

Пакет testing играет ключевую роль в тестировании на языке Go. Он позволяет разработчикам создавать модульные тесты с различными типами тестовых функций. Тип testing.T предлагает методы управления выполнением тестов, такие как параллельный запуск тестов с помощью Parallel(), пропуск тестов с помощью Skip() и вызов функции завершения теста с помощью Cleanup().

Ошибки и журналы

Тип testing.T предоставляет различные практические инструменты для взаимодействия с тестовым процессом, в том числе t.Errorf(), который выводит сообщение об ошибке и определяет тест как неудачный.

Важно отметить, что t.Error* не останавливает выполнение теста. Вместо этого по завершении теста будет выдано сообщение обо всех встретившихся ошибках. Иногда целесообразнее завершить тест неудачей, в этом случае следует использовать t.Fatal*. В некоторых ситуациях для вывода информации во время выполнения теста удобно использовать функцию Log*():

func Testnumber(t *testing.T) {
 input := 5
 result := number(5)
 t.Logf("The input was %d", input)

if result != "numb" {
 t.Errorf("Result was incorrect, got: %s, want: %s.", result, "numb")
}

 t.Fatalf("Stop the test now, we have seen enough")
 t.Error("This won't be executed")
}

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

Выполнение параллельных тестов

По умолчанию тесты выполняются последовательно; метод Parallel() сигнализирует о том, что тест должен выполняться параллельно. Все тесты, вызывающие эту функцию, будут выполняться параллельно. go test обрабатывает параллельные тесты, приостанавливая каждый тест, вызывающий t.Parallel(), а затем возобновляя их параллельное выполнение, когда все непараллельные тесты будут завершены. Среда GOMAXPROCS определяет, сколько тестов может выполняться параллельно одновременно, и по умолчанию это число равно количеству процессоров.

Можно построить небольшой пример, выполняющий два подтеста параллельно. Следующий код будет одновременно тестировать number(5) и number(11):

func TestnumberParallel(t *testing.T) {
        t.Run("Test 3 in Parallel", func(t *testing.T) {
            t.Parallel()
            result := number(5)
            if result != "numb" {
                t.Errorf("Result was incorrect, got: %s, want: %s.", result, "numb")
            }
        })
        t.Run("Test 7 in Parallel", func(t *testing.T) {
            t.Parallel()
            result := number(11)
            if result != "11" {
                t.Errorf("Result was incorrect, got: %s, want: %s.", result, "11")
            }
        })
    }

Пропуск тестов

Использование метода Skip() позволяет отделить модульные тесты от интеграционных. Интеграционные тесты проверяют несколько функций и компонентов вместе и обычно выполняются медленнее, поэтому иногда целесообразно выполнять только модульные тесты. Например, go test использует параметр -test.short, который предназначен для выполнения "быстрого" теста. Однако go test не решает, являются ли тесты "короткими" или нет. Необходимо использовать комбинацию testing.Short(), которая устанавливается в true при использовании -short, и t.Skip(), как показано ниже:

func TestnumberSkiped(t *testing.T) {
        if testing.Short() {
            t.Skip("skipping test in short mode.")
        }
        result := number(5)
        if result != "numb" {
            t.Errorf("Result was incorrect, got: %s, want: %s.", result, "numb")
        }
    }

Этот тест будет выполнен, если выполнить go test -v, но будет пропущен, если выполнить go test -v -test.short.

Разборка и очистка тестов

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

Использование метода defer выглядит следующим образом:

func Test_With_Cleanup(t *testing.T) {
// Some test code

defer cleanup()
// More test code
}

Хотя это достаточно просто, основной аргумент против применения метода defer заключается в том, что он может усложнить настройку тестовой логики и загромоздить тестовую функцию, если в ней задействовано много компонентов.

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

func Test_With_Cleanup(t *testing.T) {

// Some test code here
 t.Cleanup(func() {
// cleanup logic
})
// more test code here
}

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

func helper(t *testing.T) {
 t.Helper()
// do something
}

func Test_With_Cleanup(t *testing.T) {
// Some test code here
helper(t)
// more test code here
}

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

func TestnumberrTempDir(t *testing.T) {
 tmpDir := t.TempDir()
// your tests
}

Надеюсь, что вы сможете почерпнуть из этого блога то, ради чего вы сюда пришли.

Источник:

#Golang #Testing
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу