Юнит-тестирование HTTP-клиентов в Go

• 14 мин чтения

По моей практике код, обращающийся в стороннее API, покрыт тестами хуже остального: тесты стучатся в реальную сеть или их просто нет. В стандартной библиотеке для тестов HTTP API клиентов есть два основных способа тестирования. Каждый со своей областью применения: запуск сервера через httptest.NewServer и подмена http.RoundTripper. Разберём, какой способ использовать и для каких случаев, как покрыть тестами сложные кейсы такие как: возврат кодов ошибок, битые ответы, таймауты, отмену по контексту и ретраи.

Что вообще тестировать в HTTP-клиенте

Сначала стоит договориться о границе. Мы не тестируем сеть, TCP и чужой сервер. Мы тестируем свой код между вызовом метода и возвратом результата. Конкретно:

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

Вот клиент, который будем тестировать:

package apiclient

type User struct {
	Login       string `json:"login"`
	Name        string `json:"name"`
	PublicRepos int    `json:"public_repos"`
}

// APIError — ошибка, которую вернул сервер.
type APIError struct {
	StatusCode int
	Message    string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("api: статус %d: %s", e.StatusCode, e.Message)
}

type Client struct {
	baseURL string
	http    *http.Client
}

// NewClient принимает базовый URL и HTTP-клиент. Оба параметра — точки подмены в тестах.
func NewClient(baseURL string, hc *http.Client) *Client {
	if hc == nil {
		hc = &http.Client{Timeout: 5 * time.Second}
	}
	return &Client{baseURL: baseURL, http: hc}
}

func (c *Client) GetUser(ctx context.Context, login string) (*User, error) {
	u := c.baseURL + "/users/" + url.PathEscape(login)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, fmt.Errorf("создание запроса: %w", err)
	}
	req.Header.Set("Accept", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("запрос: %w", err)
	}
	defer func() {
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
		return nil, &APIError{StatusCode: resp.StatusCode, Message: string(body)}
	}

	var user User
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		return nil, fmt.Errorf("разбор ответа: %w", err)
	}
	return &user, nil
}

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

Два способа подменить сервер

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

httptest.NewServer подменяет удалённый сервер. Поднимается настоящий HTTP-сервер на случайном порте localhost. Весь клиентский стек остаётся реальным: http.Transport, TCP-соединение, разбор статусной строки и заголовков, прозрачная распаковка gzip, keep-alive.

Кастомный http.RoundTripper подменяет транспорт. Сети нет вообще. Всё, что выше транспорта, продолжает работать: http.Client по-прежнему обрабатывает редиректы, cookie jar и client.Timeout. Всё, что ниже, вы конструируете руками.

Пример различий. Проверил на коде: с настоящим Transport запрос уходит с заголовком Accept-Encoding: gzip, который клиент не выставлял. Его добавляет транспорт. С поддельным RoundTripper этого заголовка нет, потому что добавлять его больше некому. А вот на редиректе 302 поддельный транспорт вызовут дважды: на исходный запрос и на переход по Location. Редиректами занимается http.Client, а он в обоих случаях реальный.

ЗадачаИнструмент
Маппинг ответа в структуру, коды, заголовкиNewServer
Проверить URL и query, которые собрал клиентNewServer
Ошибка транспорта: connection refused, DNS, обрыв TLSRoundTripper
Битый ответ: обрыв тела, неверный Content-LengthRoundTripper
gzip, chunked, keep-alive, переиспользование соединенийNewServer
TLS и проверка сертификатаNewTLSServer
Клиент создаёт http.Client внутри, но принимает baseURLNewServer
Клиент хардкодит абсолютный URL, но *http.Client инжектитсяRoundTripper
Сотни табличных кейсов, важна скоростьRoundTripper

Правило по умолчанию: берите NewServer для максимальной честности и реалистичности тестов. Собранный руками *http.Response непоказателен, потому что можно описать ответ, который настоящий Transport никогда не вернёт, и получить зелёный тест на невозможном сценарии. Через NewServer вы физически можете отправить только реальные сетевые запросы.

Переходите на RoundTripper, когда нужен слой ниже сервера: ошибки соединения и повреждённые ответы. Через NewServer их воспроизводят хаками: закрыть сервер до запроса, перехватить соединение и оборвать его на середине. Это длиннее и хрупче, чем вернуть нужную ошибку одной строкой.

Базовый вариант: httptest.NewServer

httptest.NewServer принимает обычный http.Handler и поднимает сервер на свободном порте. Порт вы не выбираете и не должны: адрес лежит в srv.URL. Такой подход реализован специально, потому что хардкод портов в тестах сломал бы параллельные прогоны и запуски внутри CI/CD пайплайнов.

func TestGetUser_Server(t *testing.T) {
	var gotPath, gotAccept string

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotAccept = r.Header.Get("Accept")

		w.Header().Set("Content-Type", "application/json")
		io.WriteString(w, `{"login":"octocat","name":"The Octocat","public_repos":8}`)
	}))
	defer srv.Close()

	client := NewClient(srv.URL, srv.Client())

	user, err := client.GetUser(context.Background(), "octocat")
	require.NoError(t, err)

	assert.Equal(t, "/users/octocat", gotPath)
	assert.Equal(t, "application/json", gotAccept)
	assert.Equal(t, "octocat", user.Login)
	assert.Equal(t, 8, user.PublicRepos)
}

Обратите внимание на srv.Client(). Этот метод возвращает *http.Client, уже настроенный на конкретный тестовый сервер. У него собственный Transport, то есть свой пул соединений, отдельный от http.DefaultTransport. Для обычного HTTP это почти не заметно, но для работы с TLS разница принципиальна, и об этом ниже.

Хендлер здесь работает как двусторонняя проверка валидности: он и отдаёт ответ, и фиксирует то, что пришло. Проверять запрос прямо в хендлере через t.Errorf не стоит. Если тест упадёт в горутине сервера, сообщение будет труднее связать с причиной. Лучше сохранить значения в переменные и проверить их в теле теста, как выше.

RoundTripper как мок

http.RoundTripper это интерфейс с одним методом. Значит, его можно реализовать функцией:

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

func newTestClient(fn roundTripFunc) *http.Client {
	return &http.Client{Transport: fn}
}

Восемь строк и без зависимостей. Теперь клиент можно протестировать, не открывая сокет и не используя сетевой стек вовсе:

func TestGetUser_RoundTripper(t *testing.T) {
	var gotURL string

	hc := newTestClient(func(r *http.Request) (*http.Response, error) {
		gotURL = r.URL.String()
		return &http.Response{
			StatusCode: http.StatusOK,
			Header:     http.Header{"Content-Type": []string{"application/json"}},
			Body:       io.NopCloser(strings.NewReader(`{"login":"octocat"}`)),
			Request:    r,
		}, nil
	})

	client := NewClient("https://api.github.com", hc)

	user, err := client.GetUser(context.Background(), "octocat")
	require.NoError(t, err)

	assert.Equal(t, "https://api.github.com/users/octocat", gotURL)
	assert.Equal(t, "octocat", user.Login)
}

Здесь виден побочный плюс: базовый URL остался настоящим. Тест проверяет ровно тот адрес, в который клиент пойдёт в проде. С NewServer вы всегда подставляете srv.URL, и опечатка в домене боевого конфига тестом не ловится.

При конструировании *http.Response вручную есть несколько полей, про которые стоит помнить.

Body задавайте всегда, даже пустой: io.NopCloser(strings.NewReader("")). Сам http.Client к nil терпим, он подставит пустое тело за вас, паники не будет. Но если тестируемый код вызывает RoundTrip напрямую, минуя клиент, то resp.Body.Close() уронит его на nil-интерфейсе. Проще не полагаться на подстановку.

Header тоже лучше инициализировать, а не оставлять нулевым: потому что запись в nil-map паникует, а её возможно сделать в коде, который читает и правит заголовки ответа. Поле Request заполняйте входящим запросом. На него смотрит логика редиректов и часть кода, читающего resp.Request.URL.

Ошибки транспорта

Вот то, ради чего RoundTripper в основном и используют в тестировании. Сервер неспособен ответить «соединение не установилось». Если он отвечает, соединение уже установилось.

var errDial = errors.New("dial tcp: connection refused")

func TestGetUser_TransportError(t *testing.T) {
	hc := newTestClient(func(*http.Request) (*http.Response, error) {
		return nil, errDial
	})

	_, err := NewClient("https://api.github.com", hc).GetUser(context.Background(), "octocat")

	require.Error(t, err)
	assert.ErrorIs(t, err, errDial)
}

Данный тест проверит, что клиент обернул ошибку через %w и не потерял оригинал.

Таким же образом мы можем моделировать обрыв тела на середине. Возможный случай: сервер отдал заголовки, начал писать JSON и упал с паникой.

type errReader struct{}

func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }

func TestGetUser_TruncatedBody(t *testing.T) {
	hc := newTestClient(func(r *http.Request) (*http.Response, error) {
		return &http.Response{
			StatusCode: http.StatusOK,
			Header:     http.Header{},
			Body:       io.NopCloser(io.MultiReader(strings.NewReader(`{"login":"oct`), errReader{})),
			Request:    r,
		}, nil
	})

	_, err := NewClient("https://api.github.com", hc).GetUser(context.Background(), "octocat")

	require.Error(t, err)
	assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
}

io.MultiReader отдаёт корректное начало, а потом ошибку. Так мы повторяем обрыв потока ответа так, как его видит читающий код.

Табличные тесты на коды и битые тела

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

func TestGetUser_Errors(t *testing.T) {
	tests := []struct {
		name       string
		status     int
		body       string
		wantStatus int
		wantErrMsg string
	}{
		{"не найден", http.StatusNotFound, `{"message":"Not Found"}`, http.StatusNotFound, ""},
		{"лимит", http.StatusTooManyRequests, `rate limit`, http.StatusTooManyRequests, ""},
		{"ошибка сервера", http.StatusInternalServerError, ``, http.StatusInternalServerError, ""},
		{"битый JSON", http.StatusOK, `{"login":`, 0, "разбор ответа"},
		{"пустое тело", http.StatusOK, ``, 0, "разбор ответа"},
		{"HTML вместо JSON", http.StatusOK, `<html>502</html>`, 0, "разбор ответа"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				w.WriteHeader(tt.status)
				io.WriteString(w, tt.body)
			}))
			defer srv.Close()

			_, err := NewClient(srv.URL, srv.Client()).GetUser(context.Background(), "octocat")
			require.Error(t, err)

			var apiErr *APIError
			if tt.wantStatus != 0 {
				require.ErrorAs(t, err, &apiErr)
				assert.Equal(t, tt.wantStatus, apiErr.StatusCode)
				return
			}
			assert.NotErrorAs(t, err, &apiErr)
			assert.Contains(t, err.Error(), tt.wantErrMsg)
		})
	}
}

assert.NotErrorAs появилась в testify v1.10.0. На более старых версиях её в пакете нет, там придётся писать assert.False(t, errors.As(err, &apiErr)).

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

Каждый подтест поднимает свой сервер, поэтому t.Parallel() здесь безопасен. Если бы сервер был один на всю таблицу, а внутри хендлера жил общий счётчик, параллельный прогон дал бы гонку.

Таймауты и отмена по контексту

Тестировать таймаут через time.Sleep в хендлере плохая идея. Тест становится медленным и мигающим: на нагруженном CI сон в 100 мс легко превращается в 300 мс. Правильный приём — хендлер, который ждёт отмены со стороны клиента.

func TestGetUser_ContextDeadline(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Ждём отмены со стороны клиента, а не фиксированный sleep.
		<-r.Context().Done()
	}))
	defer srv.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	_, err := NewClient(srv.URL, srv.Client()).GetUser(ctx, "octocat")

	require.Error(t, err)
	assert.ErrorIs(t, err, context.DeadlineExceeded)
}

Хендлер завершится ровно тогда, когда клиент разорвёт запрос. Тест не зависит от того, кому и сколько планировщик раздал время. Тот же приём работает и для явной отмены. Там проверяется context.Canceled.

Отдельный вопрос: чем client.Timeout отличается от дедлайна контекста. Здесь легко ошибиться, поэтому я перепроверил всё в коде. client.Timeout даёт ошибку с текстом context deadline exceeded (Client.Timeout exceeded while awaiting headers), и она тоже проходит errors.Is(err, context.DeadlineExceeded):

func TestGetUser_ClientTimeout(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		<-r.Context().Done()
	}))
	defer srv.Close()

	hc := srv.Client()
	hc.Timeout = 50 * time.Millisecond

	_, err := NewClient(srv.URL, hc).GetUser(context.Background(), "octocat")

	require.Error(t, err)

	// client.Timeout тоже проходит проверку на context.DeadlineExceeded.
	assert.ErrorIs(t, err, context.DeadlineExceeded)
	// Отличить его от дедлайна контекста можно только по тексту.
	assert.Contains(t, err.Error(), "Client.Timeout")

	// Универсальная проверка «это таймаут» — через net.Error.
	var netErr net.Error
	require.ErrorAs(t, err, &netErr)
	assert.True(t, netErr.Timeout())
}

Практический вывод: не стройте логику на попытке отличить один таймаут от другого через errors.Is. Не получится, разделяет их только текст. Если нужно ответить на вопрос «это вообще таймаут?», проверяйте net.Error и его метод Timeout(). Он честно вернёт true в обоих случаях.

Ретраи

Ретраи это код, который почти невозможно проверить глазами и очень легко проверить тестом. Два вопроса, на которые тест должен ответить: сколько раз повторили и дошло ли тело запроса до сервера на каждой попытке.

Чтобы тест не ждал реальный backoff, ожидание выносится в поле структуры:

type RetryClient struct {
	http     *http.Client
	attempts int
	// sleep вынесен в поле, чтобы тест не ждал реального времени.
	sleep func(context.Context, time.Duration) error
}

func NewRetryClient(hc *http.Client, attempts int) *RetryClient {
	return &RetryClient{
		http:     hc,
		attempts: attempts,
		sleep: func(ctx context.Context, d time.Duration) error {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(d):
				return nil
			}
		},
	}
}

func (c *RetryClient) Do(req *http.Request) (*http.Response, error) {
	var lastErr error

	for attempt := 1; attempt <= c.attempts; attempt++ {
		// Тело запроса читается один раз. Для повтора нужна свежая копия.
		if attempt > 1 && req.Body != nil && req.Body != http.NoBody {
			if req.GetBody == nil {
				// Молча отправить пустое тело хуже, чем упасть с внятной ошибкой.
				return nil, fmt.Errorf("повтор невозможен: у запроса не задан GetBody")
			}
			body, err := req.GetBody()
			if err != nil {
				return nil, fmt.Errorf("перечитывание тела: %w", err)
			}
			req.Body = body
		}

		resp, err := c.http.Do(req)
		if err != nil {
			lastErr = err
		} else if resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests {
			return resp, nil
		} else {
			lastErr = &APIError{StatusCode: resp.StatusCode}
			io.Copy(io.Discard, resp.Body)
			resp.Body.Close()
		}

		if attempt == c.attempts {
			break
		}
		backoff := time.Duration(1<<(attempt-1)) * 100 * time.Millisecond
		if err := c.sleep(req.Context(), backoff); err != nil {
			return nil, err
		}
	}

	return nil, fmt.Errorf("после %d попыток: %w", c.attempts, lastErr)
}

Ключевая деталь это req.GetBody. Тело запроса это io.ReadCloser, оно читается один раз. На второй попытке сервер получит пустое тело, если не взять свежую копию. http.NewRequest заполняет GetBody автоматически, когда тело передано как *bytes.Reader, *bytes.Buffer или *strings.Reader. Для произвольного io.Reader поле останется nil. Наивная реализация в этом случае молча отправит на второй попытке пустоту, поэтому в коде выше такой запрос сразу падает с внятной ошибкой. Тест проверяет обратный случай — что тело действительно дошло целиком:

func TestRetry_SucceedsOnThirdAttempt(t *testing.T) {
	var calls atomic.Int32

	var mu sync.Mutex
	var bodies []string

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)

		// Хендлер работает в горутине сервера, поэтому слайс под мьютексом.
		mu.Lock()
		bodies = append(bodies, string(b))
		mu.Unlock()

		if calls.Add(1) < 3 {
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
		io.WriteString(w, "ok")
	}))
	defer srv.Close()

	client := NewRetryClient(srv.Client(), 3)
	// Убираем реальное ожидание: тест не должен зависеть от часов.
	var slept []time.Duration
	client.sleep = func(_ context.Context, d time.Duration) error {
		slept = append(slept, d)
		return nil
	}

	req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(`{"x":1}`))
	require.NoError(t, err)

	resp, err := client.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()

	assert.Equal(t, http.StatusOK, resp.StatusCode)
	assert.Equal(t, int32(3), calls.Load())
	// Тело дошло целиком на каждой попытке, а не опустело после первой.
	mu.Lock()
	assert.Equal(t, []string{`{"x":1}`, `{"x":1}`, `{"x":1}`}, bodies)
	mu.Unlock()
	assert.Equal(t, []time.Duration{100 * time.Millisecond, 200 * time.Millisecond}, slept)
}

Подменённый sleep не только ускоряет тест, но и позволяет проверить сам backoff: 100 мс, потом 200 мс. Без этого экспоненциальная задержка остаётся непроверенной, и ошибка в формуле выясняется в проде.

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

func TestRetry_NoRetryOn404(t *testing.T) {
	var calls atomic.Int32

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls.Add(1)
		w.WriteHeader(http.StatusNotFound)
	}))
	defer srv.Close()

	client := NewRetryClient(srv.Client(), 3)
	client.sleep = func(context.Context, time.Duration) error { return nil }

	req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
	resp, err := client.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()

	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
	assert.Equal(t, int32(1), calls.Load())
}

Счётчик здесь atomic.Int32, а не обычный int. Даже если сейчас тест не параллельный, детектор гонок в CI рано или поздно поймает соседний тест, который решили распараллелить.

TLS

Для HTTPS есть httptest.NewTLSServer. Он поднимает сервер с самоподписанным сертификатом, которому системное хранилище, разумеется, не доверяет. Отсюда соблазн выставить InsecureSkipVerify: true в тестовом транспорте. Это то, что потом мигрирует в продовый конфиг. Сталкивался с таким на практике.

Делать этого не нужно, потому что srv.Client() уже настроен на сертификат конкретного сервера:

func TestGetUser_TLS(t *testing.T) {
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, `{"login":"octocat"}`)
	}))
	defer srv.Close()

	// srv.Client() уже знает сертификат сервера. InsecureSkipVerify не нужен.
	user, err := NewClient(srv.URL, srv.Client()).GetUser(context.Background(), "octocat")

	require.NoError(t, err)
	assert.Equal(t, "octocat", user.Login)
}

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

func TestGetUser_TLS_UnknownCA(t *testing.T) {
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
	defer srv.Close()

	// Обычный http.Client не доверяет сертификату тестового сервера.
	_, err := NewClient(srv.URL, &http.Client{}).GetUser(context.Background(), "octocat")

	require.Error(t, err)
	assert.Contains(t, err.Error(), "certificate")
}

Типичные ошибки

Забытый resp.Body.Close(). Соединение не вернётся в пул и утечёт. В тестах это почти не видно, в проде видно очень хорошо. Используйте линтер bodyclose, чтобы отловить эту ошибку до прода.

Непрочитанное тело. Закрыть мало. Если тело не дочитано до конца, Transport не сможет переиспользовать соединение и откроет новое. Отсюда io.Copy(io.Discard, resp.Body) перед Close в примере выше. Подробно про то, во что это обходится, написано в статье про переиспользование соединений в HTTP-клиенте.

Ассерты внутри хендлера. Хендлер выполняется в горутине сервера. t.Fatal оттуда не остановит тест так, как вы ожидаете: он завершит горутину сервера, а тест зависнет на запросе. Собирайте данные в переменные и проверяйте их после.

Общее состояние в параллельных подтестах. Один сервер на всю таблицу плюс счётчик внутри хендлера равно гонка. Либо свой сервер на подтест, либо atomic.

Хардкод порта. httptest даёт свободный порт сам. Фиксированный порт превращает параллельный прогон в лотерею.

InsecureSkipVerify в тестах. Есть srv.Client(), он решает задачу без ослабления проверок.

Тест на поддельном ответе, которого не бывает. Обратная сторона RoundTripper. Если сомневаетесь, что настоящий сервер может отдать такой ответ, перепишите тест на NewServer.

Итог

Короткий чеклист, что чем закрывать:

Что проверяемИнструмент
Запрос собран верно, ответ разобран верноhttptest.NewServer
Коды 4xx и 5xx дают различимые ошибкиhttptest.NewServer + табличный тест
Битый JSON, HTML вместо JSON, пустое телоhttptest.NewServer
Ошибки соединения и обрыв телакастомный RoundTripper
Таймаут и отмена по контекстуNewServer + хендлер, ждущий r.Context().Done()
Ретраи: количество, backoff, перечитывание телаNewServer + счётчик + подменённый sleep
TLShttptest.NewTLSServer + srv.Client()

Всё это стандартная библиотека плюс testify для ассертов. Внешние моки HTTP не нужны: восемь строк roundTripFunc покрывают то, ради чего их обычно тянут в проект.

Общие приёмы тестирования в Go (табличные тесты, подтесты, моки через интерфейсы, покрытие) разобраны отдельно в практическом руководстве по тестированию в Go.


Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: