Некоторые особенности тестирования в Go, или как мокать переменные окружения

В этой статье я хочу поделиться своими мыслями о трудностях, которые возникают в ходе написания тестов на Go и о том, как эти трудности преодолеть.

Из статьи вы узнаете:

  • Почему код зависящий от переменных окружения — это проблема?
  • Почему не стоит использовать переменные окружения напрямую?
  • Как избавится от этой зависимости?
  • Как правильно “мокать” переменные окружения?
  • Как сделать этот процесс чуть более приятным?

Говорить мы будем в контексте языка Go, так как именно в процессе разработки на нём я столкнулся с необходимостью бороться с неудобствами, о которых расскажу ниже.

Чем хороши ENV переменные?

Да почти всем 👍! Это действительно прекрасный способ конфигурировать приложение и менять логику его работы в зависимости от среды, в которой оно запущено.

Чем плохи ENV переменные?

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

Допустим у нас есть следующая функция:

func MyFunc() int {
    if os.Getenv("MY_ENV") {
        return 1
    }
    
    return 2
}

Для того, чтобы покрыть обе ветки кода, в тесте нам придётся задавать значение переменной MY_ENV следующим образом:

func TestMyFunc(t *testing.T) {
    os.Setenv("MY_ENV", true)
    if res := MyFunc(); res != 1 {
        // TODO: handle test error
    }
}
Ну, выглядит несложно, в чём проблема?

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

Не используйте ENV переменные напрямую

Писать код на Go принято так, чтобы его было легко тестировать, а значит удобно "мокать". Переменные окружения в этом плане не самые приятные ребята.

Так что следует отказаться от использования переменных окружения напрямую (через os.Getenv("MY_ENV")) и использовать более удобные обёртки.

Прекрасным решением для хранения параметров, загруженных из переменных окружения, будет пакет viper, так как значения в нём можно "замокать" используя viper.Set("my_env", "value").

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

Использование обёртки — не панацея

Суть в том, что даже если вы откажетесь от использования переменных окружения напрямую, это не означает, что вы не столкнётесь с ними при тестировании.

Если вы попытаетесь написать свою обёртку для работы с переменными среды, то тут без вариантов, тестировать написанный код придётся.

Если использовать viper (или другое аналогичное решение), то можно избежать необходимости тестирования вовсе, но не всегда.

Рассмотрим реальный пример.

Имеем написанный нами пакет config, задачей которого является загрузка конфигураций.

package configs

import (
	"strings"

	"github.com/spf13/viper"
)

// Load loads configurations variables.
func Load() {
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	viper.SetEnvPrefix("ptl")

	viper.SetDefault("db.host", "db")
	viper.SetDefault("db.port", "5432")
	viper.SetDefault("db.user", "postgres")
	viper.SetDefault("db.pass", "secret")
	viper.SetDefault("db.name", "my_database")

	viper.SetDefault("test.db.host", "db")
	viper.SetDefault("test.db.port", "5432")
	viper.SetDefault("test.db.user", "postgres")
	viper.SetDefault("test.db.pass", "secret")
	viper.SetDefault("test.db.name", "test_my_database")

	viper.AutomaticEnv()
}

В данном случае всю работу за нас делает пакет viper. Но можем ли мы быть уверены, что код будет работать так, как мы ожидаем?

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

Не ленитесь - пишите тесты 👨‍✈️!

Лично мне, в данном случае, хочется быть уверенным, что я правильно использовал пакет viper, и если в системе будут установлены переменные с префиксом "PTL_" (viper.SetEnvPrefix("ptl")), то их значения загрузятся корректно и будут доступны по определённым ключам viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")).

Как тестировать?

Мокать! Другого выхода нет.

Но обязательно нужно позаботиться о возвращении значения переменной окружения в исходное состоянии по завершении теста.

Именно в изначальное состояние, если мы конечно хотим сделать всё правильно.

Например, можно поступить как-то так:

TestMyFunc(t *testing.T) {
    original := os.Getenv("MY_ENV")
    os.Setenv("MY_ENV", "new_val")
    defer os.Setenv("MY_ENV", original)
    ...
    ...
}

Это решение будет некорректным, если до этого переменная не была установлена. Определить это можно при помощи функции os.LookupEnv.

В этом случае код теста может иметь вид:

TestMyFunc(t *testing.T) {
    original, present := os.LookupEnv("MY_ENV")
    os.Setenv("MY_ENV", "new_val")
    if present {
        defer os.Setenv("MY_ENV", original)
    } else {
        defer os.Unsetenv("MY_ENV")
    }
    ...
    ...
}

Кода становится всё больше и его читаемость становиться всё хуже. А необходимость "мокать" множество переменных окружения только усугубит ситуацию.

Изменяем значения переменных окружения вручную

Для простоты понимания я не буду пытаться переиспользовать код тестов используя testify suite, а также не буду использовать сабтесты для отделения тестового случая, когда переменные среды заданы от случая, когда пакет должен применить значения по умолчанию (функция TestLoad будет просто продублирована для каждого случая).

Рассмотрим пример, в котором у нас есть вышеупомянутая функция configs.Load. Мы хотим протестировать два случая:

  1. Переменные окружения заданы, и мы хотим убедиться, что в viper попали именно те значения, которые мы ожидаем
  2. Переменные окружения не установлены, и в viper должны попасть значения по умолчанию

Принципиальным отличием этих случаев является то, что в первом случае нужно изменить значения переменных, а во втором переменные нужно удалить используя os.Unsetenv.

Устанавливаем значения переменных окружения

func TestLoad(t *testing.T) {
	envVars := map[string]string{"PTL_DB_HOST": "localhost", "PTL_DB_PORT": "5454"}

	originalValues := make(map[string]string)

	for name, value := range envVars {
		originalValues[name] = os.Getenv(name)
		os.Setenv(name, value)
	}

	defer func() {
		// На момент первого написания ещё не была реализована логика
		// для удаления переменных, не установленных до запуска теста.
		for name, value := range originalValues {
			os.Setenv(name, value)
		}
	}()

	etalonKeyValues := map[string]string{"db.host": "localhost", "db.port": "5454"}

	Load()
	for key, value := range etalonKeyValues {
		t.Run(fmt.Sprintf("key %s value %s", key, value), func(t *testing.T) {
			assert.Equal(t, value, viper.Get(key))
		})
	}
}

Сбрасываем переменные окружения

func TestLoad(t *testing.T) {
	envVars := []string{"PTL_DB_HOST", "PTL_DB_PORT"}

	originalValues := make(map[string]string)

	for _, name := range envVars {
		originalValues[name] = os.Getenv(name)
		os.Unsetenv(name)
	}

	defer func() {
		// Не учитывается ситуация, когда указанная переменная среды, изначально не была задана
		// и восстанавливать её не нужно.
		for name, value := range originalValues {
			os.Setenv(name, value)
		}
	}()

	etalonKeyValues := map[string]string{"db.host": "localhost", "db.port": "5454"}

	Load()
	for key, value := range etalonKeyValues {
		t.Run(fmt.Sprintf("key %s value %s", key, value), func(t *testing.T) {
			assert.Equal(t, value, viper.Get(key))
		})
	}
}

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

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

Используем пакет envrtr

Чтобы избавить себя от рутины, я решил оформить функционал установки значений для переменных окружения и их откат в исходное состояние в виде пакета 👌 под названием envrtr (environment retractor).

Перепишем наши тесты, но уже используя пакет.

Устанавливаем значения переменных окружения

func TestLoad(t *testing.T) {
    envVars := map[string]string{"PTL_DB_HOST": "localhost", "PTL_DB_PORT": "5454"}

    r := envrtr.NewEnvRetractor(envVars).PullOut()
    defer r.Retract()

    etalonKeyValues := map[string]string{"db.host": "localhost", "db.port": "5454"}

    Load()
	for key, value := range etalonKeyValues {
		t.Run(fmt.Sprintf("key %s value %s", key, value), func(t *testing.T) {
			assert.Equal(t, value, viper.Get(key))
		})
	}
}

Сбрасываем переменные окружения

func TestLoad(t *testing.T) {
	envVars := []string{"PTL_DB_HOST", "PTL_DB_PORT"}

	r := envrtr.NewEnvUnsetRetractor(envVars).PullOut()
	defer r.Retract()

	etalonKeyValues := map[string]string{"db.host": "localhost", "db.port": "5454"}

	Load()
	for key, value := range etalonKeyValues {
		t.Run(fmt.Sprintf("key %s value %s", key, value), func(t *testing.T) {
			assert.Equal(t, value, viper.Get(key))
		})
	}
}

Какие преимущества мы получили перед изначальной версией тестов:

  1. Использование пакета уменьшило количество кода и повысило читаемость;
  2. Функционал пакета покрыт тестами, что даёт уверенность в правильности его работы;
  3. Так как это пакет, его легко и удобно переиспользовать;
  4. Пакет учитывает изначальное состояние переменных среды и восстановит их в первозданном виде;
  5. Если чего-то не хватает, можно всегда открыть Pull-request.

Итог

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

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

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