Когда я вижу очередную статью или видеоурок про тестирование кода, я почти уверен, что мне опять расскажут про моки.

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

Концепция мок-объектов

Концепция мок-объектов была впервые представлена в статье Endo-Testing: Unit Testing with Mock Objects на конференции eXtreme Programming and Agile Processes в 1999 году. И уже в этой статье написано, что моки — это не просто прокаченные заглушки, это целая парадигма, которая предлагает новый способ тестирования через проверку поведения вместо проверки состояния (Behavior vs State verification).

С тех пор появилось две стратегии написания тестов и несколько терминов, которые описывают одно и то же: Behavior vs State verification, Mockist vs Classical testing strategy, White-box vs Black-box testing, London vs Detroit Schools of Test-Driven Development.

Если гуглить эти термины, то можно найти¹ множество² статей³ с критикой⁴ мокисткого подхода, при этом мокисткий подход сейчас предлагается как тестирование по умолчанию, а классический подход даже не упоминается. Хочется напомнить о проблемах, которые несёт с собой тестирование через проверку поведения, и что существует альтернатива.

Для примера возьмём веб-приложение, написанное с использованием архитектурного паттерна Controller-Service-Repository.

CSR устроен как цепочка слоёв, где контроллер принимает http-запрос, передаёт его в сервис, который реализует бизнес-логику, а сервис обращается к репозиторию для работы с базой данных.

В нашем примере изображён http-метод /UpdateItem, который принимает данные запроса в контроллере, парсит и валидирует их. Далее в сервисе эти данные преобразуются в формат, понятный репозиторию, и передаются ему в методе repo.UpdateItem. Далее репозиторий формирует sql-запрос и отправляет его в базу данных.

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

Казалось бы, дело сделано, теперь мы можем написать юнит-тесты, подменив вложенные компоненты моками. Но насколько хороши такие тесты?

Какие тесты мы хотим видеть

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

Хорошие тесты позволяют:

  • Ловить реальные ошибки до того, как они попадут в прод.

  • Поддерживать уверенность, при рефакторинге менять код без страха всё сломать.

  • Документировать поведение системы, особенно для сложных или неочевидных случаев.

  • Снижать стоимость изменений. Чем раньше найдена ошибка, тем дешевле её исправить.

Какими должны быть хорошие тесты:

  • Надёжными — не ломаться без причины и не флакать.

  • Читаемыми — легко понять, что именно проверяется и почему.

  • Быстрыми — чтобы их удобно было запускать локально и в CI.

  • Значимыми — проверяют поведение, а не реализацию.

  • Устойчивыми — не требуют переписывания при каждом рефакторинге.

В чём вообще проблема с моками

1. Лишние интерфейсы

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

У этой проблемы даже есть специальный термин Interface Pollution, а также проблема подробно описана в книгах 100 ошибок в Go и как их избежать и Learn Go with tests.

Разработчики, пришедшие из языков вроде C# или Java, склонны создавать избыточное количество интерфейсов, и не считают это проблемой. Но в Go так не делается.

Rob Pike (создатель Go) в своём выступлении на GopherCon подчёркивает:

Don't design with interfaces, discover them.

То есть не придумывай интерфейсы заранее, пусть они естественным образом возникнут из кода.

В этом же выступлении он говорит о том, что чем больше методов описывает интерфейс, тем менее он полезен:

The bigger the interface, the weaker the abstraction.

Это записано как один из постулатов Go.

Часто разработчики создают интерфейсы заранее, чтобы подготовить код для тестов, которые они напишут когда-нибудь потом, когда будет время. Я уверен, что в коммерческой разработке такое время не настанет никогда. Юнит-тесты пишутся либо одновременно с фичей, либо вообще не пишутся! Такие интерфейсы ничто иное как Premature Abstraction. Преждевременные абстракции такое же зло, как и преждевременная оптимизация. Когда вы заранее создаёте интерфейс для использования его в тесте, помните, что вы прямо сейчас усложняете код для того, чтобы в будущем, возможно, получить пользу от его использования.

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

2. Тавтологичность

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

В данном примере у нас есть метод репозитория GetByID который возвращает пользователя по ID.

type userRepository struct {
	db pgxpool.Pool
}

func (r *userRepository) GetByID(ctx context.Context, id int) (User, error) {
	row := r.db.QueryRow(ctx, "SELECT id, name FROM users WHERE id = $1", id)
	var user User
	err := row.Scan(&user.ID, &user.Name)
	return user, err
}

Тест для него мог бы выглядеть так:

func TestUserRepository_GetByID_Tautology(t *testing.T) {
	mock, err := pgxmock.NewPool()
	require.NoError(t, err)
	defer mock.Close()

	expectedID := 1
	expectedName := "Alice"

	rows := pgxmock.NewRows([]string{"id", "name"}).
		AddRow(expectedID, expectedName)

	mock.ExpectQuery("SELECT id, name FROM users WHERE id = $1").
		WithArgs(expectedID).
		WillReturnRows(rows)

	repo := &userRepository{db: mock}
	user, err := repo.GetByID(context.Background(), expectedID)
	require.NoError(t, err)
	require.Equal(t, expectedID, user.ID)
	require.Equal(t, expectedName, user.Name)
}

Этот тест тавтологичен, потому что мок всегда возвращает "Alice" с ID 1, независимо от входного значения. Тест проверяет то, что сам и подстроил, никакой логики не проверяется.

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

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

Больше информации на эту тему:

3. Хрупкость

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

Для примера возьмём функцию, которая удаляет профили, что не заходили более 30 дней и не защищены от удаления.

package cleanup

import (
	"time"
)

type ProfileAPI interface {
	GetInactiveProfiles() ([]int64, error)
}

type ProfileDB interface {
	GetProfileData(id int64) (lastLogin time.Time, canDelete bool, err error)
	DeleteProfile(id int64) error
}

func CleanupInactiveProfiles(api ProfileAPI, db ProfileDB) error {
	ids, err := api.GetInactiveProfiles()
	if err != nil {
		return err
	}
	for _, id := range ids {
		lastLogin, protected, err := db.GetProfileData(id)
		if err != nil {
			return err
		}
		if !protected && time.Since(lastLogin) > 30*24*time.Hour {
			if err := db.DeleteProfile(id); err != nil {
				return err
			}
		}
	}
	return nil
}

Тест для неё мог бы выглядеть так:

package cleanup

import (
	"testing"
	"time"

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

type MockAPI struct{ mock.Mock }
func (m *MockAPI) GetInactiveProfiles() ([]int64, error) {
	args := m.Called()
	return args.Get(0).([]int64), args.Error(1)
}

type MockDB struct{ mock.Mock }
func (m *MockDB) GetProfileData(id int64) (time.Time, bool, error) {
	args := m.Called(id)
	return args.Get(0).(time.Time), args.Get(1).(bool), args.Error(2)
}
func (m *MockDB) DeleteProfile(id int64) error {
	args := m.Called(id)
	return args.Error(0)
}

func TestCleanupInactiveProfiles_Brittle(t *testing.T) {
	api := new(MockAPI)
	db := new(MockDB)
	now := time.Now()
	oldTime := now.Add(-31 * 24 * time.Hour)
	api.On("GetInactiveProfiles").Return([]int64{1, 2, 3}, nil)
	db.On("GetProfileData", int64(1)).Return(oldTime, false, nil)
	db.On("DeleteProfile", int64(1)).Return(nil).Once()
	db.On("GetProfileData", int64(2)).Return(oldTime, true, nil)
	db.On("GetProfileData", int64(3)).Return(now.Add(-1*time.Hour), false, nil)
	err := CleanupInactiveProfiles(api, db)
	assert.NoError(t, err)
	db.AssertNumberOfCalls(t, "DeleteProfile", 1)
	db.AssertCalled(t, "DeleteProfile", int64(1))
}

Этот тест хрупкий потому что:

  • Зависит от внутренней реализации. Если мы решим оптимизировать код и перепишем методы db.GetProfileData и db.DeleteProfile так, чтобы они могли принимать несколько id, то нам придётся переписать и тест. Хотя логика не поменялась.

  • Жёстко проверяет количество вызовов. Если изменить условие удаления (например, с 30 на 60 дней), тест сломается, хотя логика останется корректной.

  • Знает конкретные id для удаления. Тест ожидает вызов db.DeleteProfile только для id=1. При изменении тестовых данных потребуется переписывать проверки.

Как бы мы поступили, если бы моков вообще не существовало?

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

...
		if ShouldDeleteProfile(lastLogin, protected, 30*24*time.Hour) {
			if err := deleter.DeleteProfile(id); err != nil {
				return err
			}
		}
...

// ShouldDeleteProfile определяет, нужно ли удалять профиль
func ShouldDeleteProfile(lastLogin time.Time, protected bool, cutoffDuration time.Duration) bool {
	return !protected && time.Since(lastLogin) > cutoffDuration
}

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

package cleanup

import (
	"testing"
	"time"

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

func TestShouldDeleteProfile(t *testing.T) {
	now := time.Now()
	testCases := []struct {
		name           string
		lastLogin      time.Time
		protected      bool
		cutoffDuration time.Duration
		expected       bool
	}{
		{
			name:           "should delete - old and not protected",
			lastLogin:      now.Add(-31 * 24 * time.Hour),
			protected:      false,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       true,
		},
		{
			name:           "should not delete - too new",
			lastLogin:      now.Add(-29 * 24 * time.Hour),
			protected:      false,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       false,
		},
		{
			name:           "should not delete - not allowed",
			lastLogin:      now.Add(-31 * 24 * time.Hour),
			protected:      true,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       false,
		},
		{
			name:           "zero time - never logged in",
			lastLogin:      time.Time{}, // нулевое время
			protected:      false,
			cutoffDuration: 30 * 24 * time.Hour,
			expected:       true, // удаляем "мертвые" профили
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			result := ShouldDeleteProfile(tc.lastLogin, tc.protected, tc.cutoffDuration)
			assert.Equal(t, tc.expected, result)
		})
	}
}

Что мы получили:

  • Устойчивость к изменениям. Этот тест не сломается при следующем рефакторинге.

  • Простота тестирования. Нет сложных моков, только проверка входных и выходных параметров.

  • Чёткое разделение ответственности. Бизнес-логика теперь располагается отдельно от работы с внешними системами.

  • Лёгкость поддержки. Добавить новое условие удаления профиля очень просто.

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

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

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

Вызовы api.GetInactiveProfiles, db.GetProfilesData и db.DeleteProfile, которые раньше прогонялись в тесте, это всё вызовы моков, а не реального кода. На самом деле, и до, и после рефакторинга то, что мы реально тестировали, — это условие в 27 строке.

Больше примеров с рефакторингом кода, чтобы сделать его более тестируемым, можно посмотреть в докладе Victor Rentea Test Driven Design Insights : 10 Hints You Were Missing.

4. Сложность для понимания

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

Если вам приходится читать тест с моками, и вы замечаете, что мысленно прокручиваете код, который тестируется, чтобы понять суть теста, скорее всего, вы чрезмерно используете моки. Testing on the Toilet: Don't Overuse Mocks

Как обойтись без моков

1. Разделить обработку и ввод-вывод данных

Архитектурный подход Functional Core Imperative Shell предлагает выносить бизнес-логику в функциональное ядро, состоящее из чистых функций, а ввод-вывод, побочные эффекты и инфраструктурный код в императивную оболочку.

Чистая функция — это функция, которая при одних и тех же входных данных всегда возвращает один и тот же результат.

Например math.Sin, math.Sqrt, strings.TrimSpace, strings.Replace являются чистыми, их очень просто тестировать. И если мы полностью поменяем реализацию этих функции, старые тесты всё равно будут работать.

Этот подход можно совмещать с DDD, Гексагональной архитектурой или Controller-Service-Repository.

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

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

2. Понять, какие тесты вам действительно нужны

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

Нужно помнить, что сопровождение тестов небесплатное, их тоже надо поддерживать и тратить на это время программистов, поэтому не нужно стремиться к 100% покрытию кода. Большое покрытие тестами только замедляет разработку и не несёт практической пользы.

Test coverage — полезный инструмент для поиска непротестированных частей кодовой базы. Test coverage малопригоден в качестве числового показателя того, насколько хороши ваши тесты. Martin Fowler

В наше время писать плохие тесты как никогда просто: mockery сам генерирует код, а ChatGPT напишет любой тест для любой функции за секунду. Хотите 100% покрытие? Нет проблем! Но будут ли такие тесты полезны?

Общеизвестный факт, что цифры покрытия кода полезны только для менеджмента. Увеличение цифр не должно быть целью вашего набора тестов. Beyond Code Coverage

Половина работы большинства программ состоит в том, чтобы принять данные по http, обработать их и положить в базу данных. Вторая половина — это взять данные из БД, обработать их и отправить обратно.

Единственная часть, которую действительно стоит покрывать юнит-тестами — это обработка данных. I Mock Your Mocks

Jim Coplien предлагает периодически проводить ревизию тестов и безжалостно удалять тесты, которые не падали больше года.

Нужны только те тесты, которые проверяют ключевую логику и несут бизнес-ценность. Why Most Unit Testing is Waste (перевод)

3. Использовать интеграционные тесты

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

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

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

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

Также нужно вспомнить, что со времён 2000-х годов, когда были представлены моки и пирамида тестирования, прошло много времени, и сегодня у нас есть отличный инструментарий вроде Testcontainers, пакета httptest, CI/CD, облаков и быстрые компьютеры. Писать и прогонять интеграционные тесты уже не так дорого, как раньше. Почти любой проект можно поднять на компьютере разработчика за секунды. Вероятно, вы даже не заметите разницу между прогоном юнит-тестов и интеграционных тестов вашего микросервиса.

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

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

Когда использовать моки

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

  • Когда нужно подготовить определённое состояние.

  • Когда нужно тестировать отказы или специфическое поведение.

  • В некоторых случаях, когда нужно тестировать асинхронные операции, например очереди.

Подробнее о таких кейсах можно послушать в этом докладе.

Кратко

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

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

  • Ключевую логику следует выносить в чистые функции и писать юнит-тесты только для них.

  • Моки позволяют сделать процент покрытия кода выше, но это метрика, которая ничего не говорит о качестве тестирования и качестве кода.

  • Интеграционные тесты лучше тестов с моками, потому что они проверяют всю цепочку вызова, а в наше время писать и прогонять их просто, как никогда.

Ссылки

Комментарии (28)


  1. cupraer
    24.06.2025 06:49

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

    Этот тест хрупкий потому что:

    • Зависит от внутренней реализации. Если мы решим оптимизировать код и перепишем методы db.GetProfileData и db.DeleteProfile так, чтобы они могли принимать несколько id, то нам придётся переписать и тест. Хотя логика не поменялась.

    Интерфейсы плохо, потому что они мешают нам ломать обратную совместимость. Ясно. Может быть, надо просто научиться определять интерфейсы? Потому что ваша чистая функция — это и есть правильный интерфейс.

    Тестирование через поведение нужно применять тогда, когда нужно протестировать поведение. 

    Мне всегда нужно протестировать именно поведение. Интеграционные тесты, которые вы восхваляете, тестируют именно поведение.

    Интеграционные тесты лучше тестов с моками, потому что они проверяют всю цепочку вызова […]

    Ога, зато пока соседняя команда не допилит свой эндпоинт, я никаких тестов прогнать не смогу. Удобно!

    ———

    И, наконец, вишенка на торте:

    [Когда нужны моки?] — В некоторых случаях, когда нужно тестировать асинхронные операции […]

    В общем, вы в 2025 году считаете асинхронные операции «некоторыми случаями» (что неудивительно в свете выбора языка). Вся остальная индустрия в то же самое время старается буквально всё сделать асинхронным.

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


    1. AlexAkulov Автор
      24.06.2025 06:49

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

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

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

      Мне всегда нужно протестировать именно поведение. Интеграционные тесты, которые вы восхваляете, тестируют именно поведение.

      Не совсем понимаю как вы тестируете поведение в интеграционных тестах. Вы отправляете какие-то данные на эндпоинт и проверяете, что вам вернулось именно те данные которые вы ожидаете. Это тестирование состояния.

      Ога, зато пока соседняя команда не допилит свой эндпоинт, я никаких тестов прогнать не смогу. Удобно!

      Как часто такое случается на практике? Мне кажется это достаточно редкий кейс. Но даже в таком случае вы сможете прогнать самые главные тесты, тесты вашей бизнес-логики. Либо использовать WireMock или что-то подобное для эмулирования API которого ещё не существует, если хотите написать интеграционные тесты заранее.

      В общем, вы в 2025 году считаете асинхронные операции «некоторыми случаями»

      Имеется ввиду работа с очередями (кафка, рэббит). В приведённом докладе более подробно рассказан этот кейс.


      1. cupraer
        24.06.2025 06:49

        Какая связь между чистыми функциями и классическим тестированием?

        Как вы вашим классическим тестированием определите адекватность функции, которая выполняется в гринтреде/горутине, в полностью асинхронной высококонкурентной среде, в которой одновременно с ней выполняется еще 10К гринтредов/горутин?

        Код, который выполняется в изоляции, в тепличных условиях, — можно тестировать вообще как угодно (я предпочитаю property-based тесты, они хотя бы граничные случаи найдут). Да что там, его можно вообще не тестировать, такой код синьёр обязан писать в блокноте набело со скоростью набора.

        Действительно нетривиальный код в действительно параллельной среде — можно протестировать только моками, исходя из моего опыта.


        1. AlexAkulov Автор
          24.06.2025 06:49

          Действительно нетривиальный код в действительно параллельной среде — можно протестировать только моками, исходя из моего опыта.

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


          1. cupraer
            24.06.2025 06:49

            и шарят состояние между собой

            Это необязательное условие, они могут быть изолированы, просто работать с одними и теми же данными, не в шаред стейте, а хоть в базе/редисе.

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

            Из стороннего источника поступает поток данных (его можно получить и в тестах, если нужно) со скоростью 10К/сек. Пусть это температура воздуха в крупных городах. Удостовериться, что при запросе климатических условий в маленьком городе — интерполяция соседних городов всегда использует последние полученные значения.


            1. AlexAkulov Автор
              24.06.2025 06:49

              они могут быть изолированы

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

              for data := range inputCh {
              	go func(d Data) {
              		resultCh <- myFunc(d)
              	}(data)
              }
              


          1. cupraer
            24.06.2025 06:49

            Ладно, вот попроще: просто удостовериться, что три асинхронных вызова происходят всегда в нужной последовательности.


            1. AlexAkulov Автор
              24.06.2025 06:49

              У меня вся статья про то, что не нужно тестировать через поведение, нужно тестировать через проверку данных. А вы меня спрашиваете: как мне протестировать поведение?


              1. DasMeister
                24.06.2025 06:49

                Мок в первую очередь и верифицирует данные, а не поведение. Устраняя побочные эффекты и превращая функцию в определенном смысле в чистую.


                1. AlexAkulov Автор
                  24.06.2025 06:49

                  Так зачем вам моки тогда? Пишите сразу чистые функции.


                  1. DasMeister
                    24.06.2025 06:49

                    Ваша статья имеет два контекста.

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

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

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

                    Однако это не убирает смысла в i9n тестах против постгреса (почему я вас и просил продемонстрировать вашу способность как автора монументальных картинок в статье) на уровне юнита (модуля репозитория). Т.к. это позволит разработчику написать код на языке, который невозможно верифицировать (SQL) без языка приложения (go) в момент написания собственно модуля.

                    Здесь они смысл имеют и значимый. Т.к. позволяют верифицировать чистую функцию на предметном языке ( SQL). Эту функцию невозможно проверить штатными средствами её языка, а отложить её верификацию до сборки приложения целиком, может обернуться неверными эстимейтами и провалами сроков.

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

                    Если верификационная группа будет у вас 5 минут работать только на уровне 1 юнита, то на уровне всего приложения 500-700 тестов у вас будут выполняться половину дня. И такого рода тесты становятся БЕСПОЛЕЗНЫМИ для разработчика.

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

                    Именно поэтому данные (и их декларации в виде описаний структур) это существительные. А методы (или функции) это глаголы. Поэтому тестируя любой действующий юнит - вы тестируете в первую очередь поведение. А верифицируете его через результат ЕГО жизнедеятельности - в виде side effect.

                    Поэтому финальная часть вашей статьи - это бессмыслица.


          1. cupraer
            24.06.2025 06:49

            Кстати, может быть вам будет полезно прочитать вот это: https://habr.com/ru/articles/889512/


  1. qeeveex
    24.06.2025 06:49

    В golang нет проблем с интерфейсами, благодаря duck-typing.

    Статья высосана из пальца.


  1. demlord
    24.06.2025 06:49

    Спасибо за статью.

    Подчерпнул для себя много нового.


  1. summerwind
    24.06.2025 06:49

    Когда я вижу очередную статью или видеоурок про тестирование кода, я почти уверен, что мне опять расскажут про моки.

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

    Да? А у меня, наоборот, создается впечатление, что из каждого утюга стало модно кричать про то, что моки и юнит-тесты не нужны, а нужны только интеграционные тесты. Хотя, у этих инструментов в целом разное предназначение.


  1. deadlynch
    24.06.2025 06:49

    Спасибо за статью.

    Странно, что в списке литературы нет Принципов Юнит-тестирования Владимира Хорикова. Как будто конспект прочитал. Кто хочет более фундаментальные знания по данному подходу очень рекомендую к прочтению.


  1. DasMeister
    24.06.2025 06:49

    Покажите мне пример интеграционного теста на golang, функции использующей SQL запрос в Postgresql (первый же ваш пример с кодом) так, чтобы я его запустил одной командой go test, со скоростью исполнения до 100 миллисекунд. Ну и конечно, запрос к таблице которая при этом создаётся с помощью миграции. И конечно эти тесты должны быть изолируемы и исполняться в последствии на CI максимум с использованием только докера.

    Раз уж вы так пропагандируете интеграционные тесты без моков, наверняка покажете. И к слову, это должно быть регулярно используемое решение. А не полотно на 500 строк как обычно в го принято.


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


    1. AlexAkulov Автор
      24.06.2025 06:49

      Я просто вставил ваш комментарий в ChatGPT и он сгенерил рабочий тест.
      https://gist.github.com/AlexAkulov/12644f01ef36d5faade8b3cedd86fbe8
      Всё как вы хотели, но на моём М1 отрабатывает за 3 секунды.

      Я упомянул в статье https://testcontainers.com -- отличная штука, если не знаете с чего начать.


      1. DasMeister
        24.06.2025 06:49

        Код вашего GPTChat линейно скалируется по времени от числа тестов. У меня есть пакеты с постгресом где 100 тестов отрабатывают примерно 0.6 секунды. В вашем случае с вашим примером эти замечательные тесты отработают за 300 секунд. Выглядит как антиреклама тестированию.

        Я бы не задавал вопрос, если бы не знал ответ. Но вот в статье где РЕКОМЕДНУЮТ не использовать моки им уделено много времени, а вот демонстрации силы интеграции - нет.


        1. AlexAkulov Автор
          24.06.2025 06:49

          Ну ок, пусть работают 300 секунд, но найдут ошибку, чем отработают за секунду, ничего не найдут, а баг обнаружится после деплоя.


          1. DasMeister
            24.06.2025 06:49

            Ну т.е. 100 регулярных тестов (модульных) с интеграцией на 30-40 методов репозитория вы будете натурально выполнять 5 минут и считаете это нормальным.

            Правильно ли я понимаю, что вы не знаете как сделать такию группу тестов работающей за обозримое время в пормежутке до 4 секунд (максимально комфортное время исполнения любой группы тестов. после которого они начинают вредить вредить и замедлять работу)?


            1. AlexAkulov Автор
              24.06.2025 06:49

              В каких-то языках просто компиляция проекта занимает сильно больше чем 5 минут. И как-то люди живут с этим)

              У вас есть множество способов ускорить интеграционные тесты. Вы можете не удалять контейнер с Постгресом каждый раз, а просто делать drop database. Вы можете запустить БД по числу ядер и прогонять тесты параллельно. Тут большой простор для оптимизаций, но я вообще не вижу в этом проблемы. Вроде у всех уже давно есть CI/CD, просто пушишь изменения и идёшь пить чай, сколько там эти тесты прогоняются 5 секунд или 5 минут вообще не важно.


              1. DasMeister
                24.06.2025 06:49

                У нас крайне странный диалог выходит.

                Я задал вам параметризированный вопрос. Ответ и решение которого у меня есть (в виде специализированной библиотеки: https://github.com/godepo/pgrx).

                Вы же не предлагаете никакого рецепта и лишь общение с чатгпт.

                Если бы я хотел поговорить с низкоквалифицированным специалистом, вместо эксперта с креативным мышлением. Я бы с этим справился без комментариев на хабре :)

                Но пить чай за счёт работодетля, конечно удобно. Спорить не буду.


                1. AlexAkulov Автор
                  24.06.2025 06:49

                  Я не понимаю, вы таким сложным образом просто хотели посоветовать https://github.com/godepo/pgrx ?
                  Хм, ну ок, спасибо!)

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


                  1. DasMeister
                    24.06.2025 06:49

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

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

                    Негипотетический финтех сервис, поддерживаемый 3 разработчиками за полгода оброс 1200 тестами. Вы предложили этой команде убрать все моки и заменить их - интеграционными тестами. Каждый из которых будет линейно исполняться 5 секунд (суммарно 100 минут - 3 часа+).

                    Часовая авария обойдется компании в 2 миллиона долларов неполученной прибыли. Вы предложили. потратить 6 миллионов долларов на деплой quick fix'a. Лучшие решения, что я видел в исполнении других пропогандистов i9n-only тестирования работали примерно такое же количество времени.

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


  1. vacoo
    24.06.2025 06:49

    Недавно тоже пришел к такому выводу. Тесты с моками очень хрупкие. Ведь любой продукт меняется и эволюционирует каждый день. Каждый раз править моки становится очень сложно. Поэтому интеграционное тестирование и юнит тесты для меня оказались лучше. Например у меня есть три входные точки в приложении - grapqhql, http, cli. Есть слой api а под ним кучу разных слоев такие как репозиторий, внешние api, redis, очереди. Я запускаю интеграционный тест который в отдельной базе данных уничтожает все данные. Заново наполняет в соотвествии с условиями тестов и дергает api. Затем проверяю так ли должен ответить метод api при таких входных данных. Такие тесты не ломаются при каждом чихе и можно спокойно рефакторить.


    1. DasMeister
      24.06.2025 06:49

      А ваши замечательные тесты могут работать все одновременно?


      1. vacoo
        24.06.2025 06:49

        Юнит тесты да. Но интеграционные нет. Но там очень быстро все происходит. Благо сейчас компьютеры мощные. Лучше подожду пару секунд чем буду тратить часы на переделку моков