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

Пост Написание хороших юнит-тестов; Don’t Mock Database Connections впервые появился на Qvault.

Модульные тесты невероятно важны для нас как для разработчиков, потому что они позволяют продемонстрировать правильность написанного нами кода. Что еще более важно, модульные тесты позволяют нам вносить обновления в нашу кодовую базу с уверенностью, что мы ничего не сломали. Однако, стремясь получить 100% покрытие кода, мы часто пишем тесты для логики, которую, возможно, у нас нет для бизнес-тестирования. Я здесь, чтобы утверждать, что создание имитационных абстракций базы данных для написания модульных тестов — плохая идеяпочти всегда.

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

Что такое модульный тест?

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

Википедия

В Go юнит-тесты можно выполнять с помощью команды go test, и некоторые примеры исходного кода выглядят примерно так:

func TestLogPow(t *testing.T) {
	expected := math.Round(math.Log2(math.Pow(7, 8)))
	actual := math.Round(logPow(7, 8, 2))
	if actual != expected {
		t.Errorf("Expected %v, got %v", expected, actual)
	}

	expected = math.Round(math.Log2(math.Pow(10, 11)))
	actual = math.Round(logPow(10, 11, 2))
	if actual != expected {
		t.Errorf("Expected %v, got %v", expected, actual)
	}
}

Где мы тестируем функцию logPow, которая выглядит так:

// logPow calculates log_base(x^y)
// without leaving logspace for each multiplication step
// this makes it take less space in memory
func logPow(expBase float64, pow int, logBase float64) float64 {
	// logb (MN) = logb M + logb N
	total := 0.0
	for i := 0; i < pow; i++ {
		total += logX(logBase, expBase)
	}
	return total
}

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

Хороший код легче тестировать

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

Тестирование не должно быть трудным. Простой код легко тестировать.

Модульные тесты не должны зависеть от инфраструктуры

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

Взгляните на следующую функцию:

func saveUser(db *sql.DB, user *User) error {
	if user.EmailAddress == "" {
		return errors.New("user requires an email")
	}
	if len(user.Password) < 8 {
		return errors.New("user password requires at least 8 characters")
	}
	hashedPassword, err = hash(user.Password)
	if err != nil {
		return err
	}
	_, err := db.Exec(`                                                                                                                          
		INSERT INTO usr (password, email_address, created)                                                                                                                                                                                                                                                                                                                                                                                       
		VALUES ($1, $2, $3);`,
		hashedPassword, user.EmailAddress, time.Now(),
	)
	if err != nil {
		return err
	}
	return nil
}

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

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

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

func saveUser(db *sql.DB, user *User) error {
	err := validateUser(user)
	if err != nil{
		return err
	}
	user.Password, err = hash(user.Password)
	if err != nil {
		return err
	}
	if err := saveUserInDB(user); err != nil{
		return err
	}
	return nil
}

Теперь наша основная функция по сохранению пользователей разбита на три разные функции:

  • Подтвердить пользователя
  • Хэш-пароль
  • Сохраните пользователя в базе данных

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

func TestValidateUser(t *testing.T) {
	err := validateUser(&User{})
	if err == nil {
		t.Error("expected an error")
	}

	err := validateUser(&User{
		Email: "[email protected]",
		Password: "thisIsALongEnoughPassword"
	})
	if err != nil {
		t.Error("should have passed")
	}
}

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

Не издевайтесь над своими внешними зависимостями

Хотя последнее эмпирическое правило «модульные тесты не должны зависеть от инфраструктуры» довольно бесспорно, следующее является более ожесточенной темой споров. Я считаю, что большая часть насмешек над базами данных и API является результатом плохого кода, и что для решения проблемы лучший способ действий — провести рефакторинг на более мелкие функции или «модули», как мы это сделали минуту назад.

Тем не менее, некоторые инженеры лениво создавали фиктивный интерфейс базы данных и тестировали всю эту чертову штуку.

type sqlDB interface {
	Exec(query string, args ...interface{}) (sql.Result, error)
}

type mockDB struct {}

func (mdb *mockDB) Exec(query string, args ...interface{}) (sql.Result, error) {
        return nil, nil
}

func saveUser(db sqlDB, user *User) error {
	if user.EmailAddress == "" {
		return errors.New("user requires an email")
	}
	if len(user.Password) < 8 {
		return errors.New("user password requires at least 8 characters")
	}
	hashedPassword, err := hash(user.Password)
	if err != nil {
		return err
	}
	_, err := db.Exec(`                                                                                                                          
		INSERT INTO usr (password, email_address, created)                                                                                                                                                                                                                                                                                                                                                                                       
		VALUES ($1, $2, $3);`,
		hashedPassword, user.EmailAddress, time.Now(),
	)
	if err != nil {
		return err
	}
	return nil
}

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

  • Код фиктивной базы данных не используется в производстве. Мы тестируем код, который буквально не имеет значения.
  • Технически, при таком подходе мы имеем лучшее «тестовое покрытие», но на самом деле наши тесты не более надежны. Мы получаем ложное чувство безопасности.
  • Мы усложнили поиск настоящего кода, абстрагировав его за интерфейс.
  • Тот факт, что saveUser было трудно тестировать, был отличным сигналом для нас, разработчиков, о том, что он нуждается в рефакторинге. Мы заглушили хороший сигнал о том, что наш код нуждается в очистке.

Не проверяйте свои зависимости, убедитесь, что они проходят собственные тесты

Основываясь на примере рефакторинга функции saveUser ранее, у нас все еще есть две функции, которые, вероятно, зависят от сторонних библиотек, а именно функция hash и функция saveUserToDB. Если мы написали наш код хорошо, эти функции не должны делать ничего, кроме инкапсуляции API библиотек.

func hash(password string) (string, error) {
	const cost = 10
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
	return string(bytes), err
}

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

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

Спасибо за чтение!

Пройдите курсы информатики на нашей новой платформе

Подпишитесь на нас и напишите нам в Твиттере @q_vault, если у вас есть какие-либо вопросы или комментарии.

Подпишитесь на нашу рассылку, чтобы получать больше статей по программированию