Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~18 мин +20 XP

Тесты: ws-клиент в httptest

Тесты websocket go не требуют браузера и ручных проверок: websocket.Dialer против httptest.NewServer дают полный чат в одном Go-тесте, который проходит за миллисекунды. Здесь поднимем сервер на реальном loopback-порту, подключимся Dialer'ом, прогоним конверт туда и обратно и соберём table-driven набор кейсов для протокола из десятого урока.

Содержание урока · 9 разделов

Пока проверка чата выглядит как «открыл вкладку, написал сообщение, посмотрел глазами», каждый релиз собирает неявный долг: одно время я сопровождал сервис, где состояние комнаты проверяли тремя людьми вручную, и регрессия из двадцатого урока — пачка, в которой терялся последний конверт, — дожила до продакшена ровно потому, что глаза не заметили пропажу одного кадра из шести. Тест занял бы пятнадцать строк и поймал бы это на пуше. Хорошая новость в том, что websocket-тест не тяжелее HTTP-теста: с той стороны, где обычно сидит клиент, встаёт websocket.Dialer, а с этой — обычный httptest.NewServer, который слушает настоящий TCP на loopback. Никаких браузеров, никакого CI-оркестра: полный рукопожатие, обмен кадрами и закрытие укладываются в единицы миллисекунд, и набор из сотни кейсов живёт в обычном go test. Тестовая обвязка из этого урока лежит в репозитории рядом с продовым кодом и правится вместе с ним — это условие, без которого она устареет за квартал.

Тесты websocket go: сервер в httptest.NewServer#

httptest.NewServer запускает честный HTTP-сервер на случайном свободном порту, и это не упрощение, а полноценный сервер: Upgrader в его обработчике делает то же самое, что и в проде, включая CheckOrigin. Случайный порт здесь не помеха, а плюс — тесты не дерутся за 8080 и параллельно бежат без настроек. Адрес достаётся из ts.Listener.Addr().String(): Listener уже слушает, поэтому строка вида 127.0.0.1:54321 готова для Dialer'а. Главное правило — тест использует продовый обработчик, а не копию: если скопировать хендлер в тест и править только продовую версию, тест будет проверять призрак. Поэтому в тестах собирают тот же handleUpgrade из тринадцатого урока, подсовывая ему тестовый апгрейдер с ослабленным CheckOrigin, — и всё, что дальше, идёт по настоящим кадрам.

Хелпер: сервер и клиент в t.Cleanup

GO
func newEchoServer(t *testing.T) *websocket.Conn {
        t.Helper()

        upgrader := websocket.Upgrader{
                CheckOrigin: func(r *http.Request) bool { return true },
        }
        ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                conn, err := upgrader.Upgrade(w, r, nil)
                if err != nil {
                        return
                }
                for {
                        mt, msg, err := conn.ReadMessage()
                        if err != nil {
                                return
                        }
                        if err := conn.WriteMessage(mt, msg); err != nil {
                                return
                        }
                }
        }))
        t.Cleanup(ts.Close)

        url := "ws://" + ts.Listener.Addr().String() + "/ws"
        ws, _, err := websocket.Dialer{}.Dial(url, nil)
        if err != nil {
                t.Fatalf("dial: %v", err)
        }
        t.Cleanup(func() { ws.Close() })
        return ws
}

Хелпер делает t.Helper(), чтобы трейсбек ошибки указывал на строку в тесте, а не внутрь помощника. t.Cleanup регистрирует закрытие сервера и клиента: сработает после теста в обратном порядке и не потребует defer в каждом кейсе. Второй возврат Dial — http.Response — отброшен подчёркиванием: он нужен только для диагностики неудачного рукопожатия.

Чтение и запись с дедлайном#

Каждое чтение в тесте живёт под SetReadDeadline. Причина прагматичная: если сервер молчит из-за бага, ReadMessage без дедлайна будет ждать вечно, и тест съест весь таймаут go test — по умолчанию десять минут на пакет, а в CI это десять минут мёртвого пайплайна на каждый красный коммит. Дедлайн в две секунды превращает зависание в провал с внятным сообщением i/o timeout. Запись дедлайна почти не требует: она возвращает ошибку сразу, а вот чтение — единственное место, где тест честно блокируется. Нюанс table-driven кейсов: дедлайн ставится перед каждым чтением, а не один раз до цикла t.Run, иначе унаследованный таймер второго кейса уже наполовину истёк, и тест лопается на пустом месте. Для протокольных тестов удобны WriteJSON и ReadJSON: конверты из десятого урока маршалятся и разбираются структурой, и тест сравнивает поля, а не сырые байты. Общий хелпер с дедлайном экономит строку в каждом кейсе: deadline(t, ws) ставит две секунды и возвращает управление.

Первый тест: конверт уезжает и возвращается

GO
func TestEcho(t *testing.T) {
        ws := newEchoServer(t)

        if err := ws.WriteJSON(Envelope{Type: "chat", Room: "general", Text: "привет"}); err != nil {
                t.Fatalf("write: %v", err)
        }

        ws.SetReadDeadline(time.Now().Add(2 * time.Second))
        var got Envelope
        if err := ws.ReadJSON(&got); err != nil {
                t.Fatalf("read: %v", err)
        }
        if got.Text != "привет" {
                t.Fatalf("text = %q, want %q", got.Text, "привет")
        }
}

Тест прогоняет конверт по полному кругу: WriteJSON уходит в сеть, ReadJSON ждёт ответ под дедлайном в две секунды. Проверка одного поля здесь достаточна: тип и комната эхо-сервер копирует дословно. В настоящем проекте вместо эхо-цикла в хелпере стоит хаб из восьмого урока — тест не заметит подмены, потому что общается кадрами.

Table-driven для протокола сообщений#

Протокол — идеальный клиент для table-driven подхода из продвинутого курса: набор кейсов «отправляем — ожидаем» растёт одной строкой в срезе. Кейсы делятся на два сорта. Первый — валидные конверты: их проверяют сравнением полей через ReadJSON. Второй — нарушение протокола: неизвестный тип события, битый JSON, кадр больше лимита из четырнадцатого урока. Здесь ответ приходит не кадром, а закрытием соединения, и тест читает ошибку ReadMessage, распаковывая её через errors.As в websocket.CloseError: код 1009 означает превышение лимита, 1007 — невалидную полезную нагрузку. Проверка кода вместо текста ошибки надёжна: тексты gorilla меняются между версиями, коды зафиксированы RFC 6455 и не зависят от библиотеки. Цена вопроса копеечная: полный рукопожатие на loopback занимает доли миллисекунды, и сотня кейсов протокола проходит в обычном go test быстрее, чем компилируется страница браузера. Именно поэтому протокол расширяется в тестах, а не в ручных сценариях: новый тип конверта прилетает одной строкой в срез, и регрессия ловится на том же пуше.

Кейсы протокола и их проверки#

ВходОжиданиеКак проверяем
Валидный конверт chatэхо тем же текстомReadJSON и сравнение поля Text
Неизвестный тип событияошибка от хендлераReadMessage: err с текстом unknown type
Кадр 1 МБ при лимите 64 КБзакрытие 1009 message too bigerrors.As в websocket.CloseError
Битый JSONзакрытие 1007 invalid payloaderrors.As в websocket.CloseError
Просроченный токен в queryотказ рукопожатияerr от Dial: unexpected response code

Table-driven кейс: нарушение протокола

GO
func TestProtocol(t *testing.T) {
        tests := []struct {
                name string
                send string
                want string
        }{
                {"неизвестный тип", "{"type":"warp"}", "unknown type"},
                {"битый json", "{oops", "invalid character"},
        }
        for _, tc := range tests {
                t.Run(tc.name, func(t *testing.T) {
                        ws := newEchoServer(t)
                        if err := ws.WriteMessage(websocket.TextMessage, []byte(tc.send)); err != nil {
                                t.Fatalf("write: %v", err)
                        }
                        ws.SetReadDeadline(time.Now().Add(2 * time.Second))
                        _, _, err := ws.ReadMessage()
                        if err == nil || !strings.Contains(err.Error(), tc.want) {
                                t.Fatalf("err = %v, want %q", err, tc.want)
                        }
                })
        }
}

Каждый кейс получает свежий сервер через хелпер: независимость кейсов стоит одного вызова. Отправка идёт сырым WriteMessage, потому что мы сознательно нарушаем протокол — маршалинг конверта тут не нужен. Ошибка чтения проверяется подстрокой: для этих кейсов текст от хендлера стабилен, а для кодов закрытия надёжнее errors.As, как в таблице выше.

Спорное: тесты через настоящую сеть#

httptest поднимает реальный TCP на loopback, и такие тесты честнее назвать интеграционными, чем модульными. Я сознательно не мокаю conn-подобные интерфейсы для websocket: мок, написанный под текущее понимание gorilla, проверяет мок, а не библиотеку, и после каждого обновления зависимости коллекция моков начинает врать. Настоящий сокет на loopback дешевле любого мока по времени разработки и честнее по результату. Граница, где мок оправдан, — юнит-тесты сериализации: маршалинг конверта и таблица кодов закрытия проверяются без сети в микросекунды. А всё, что касается рукопожатия, лимитов и пайпов, я тестирую только через Dialer — это та часть, где мок гарантирует расхождение с продом.

Два клиента: тест приватности комнаты#

Тест с одним соединением проверяет сериализацию, но главные баги чата живут на стыке двух клиентов: подключитесь вторым Dialer'ом и проверьте, что приватное не утекает. Каркас тот же: два вызова хелпера, соединения в комнату через join, отправка от первого и чтение только у второго. Отдельный кейс — отсутствие кадра: клиент, не вошедший в комнату, не получает ничего, и проверка выглядит инвертированно — чтение под дедлайном обязано кончиться ошибкой i/o timeout, и тест утверждает именно это. Тайминг-тесты вроде «сообщение дошло за пять миллисекунд» я не пишу: на CI они лопаются от соседних процессов и превращаются в источник ложных красных. Стабильная альтернатива — счётчики: после сценария хаб отдаёт число доставленных конвертов, и тест сравнивает числа, а не время. И ещё кейс ценой в одну строку: отправка от клиента, которого в комнате нет, — хаб обязан промолчать, и это тоже проверяется дедлайном чтения.

Проверка: адрес тестового сервера

Откуда тест берёт адрес для Dialer'а?

Проверка: что будет без дедлайна

Сервер молчит из-за бага, тест читает ReadMessage без SetReadDeadline. Что произойдёт?

Два кадра в сеть, одно чтение

Прочитайте код и выберите, что он выведет:

ws := newEchoServer(t)
ws.WriteMessage(websocket.TextMessage, []byte("первый"))
ws.WriteMessage(websocket.TextMessage, []byte("второй"))

ws.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := ws.ReadMessage()
fmt.Println(string(msg), err == nil)

Соберите хелпер подключения

Впишите четыре пропуска: адрес Listener, конструктор Dialer, метод подключения и способ провалить тест.

url := "ws://" + ts..().String() + "/ws" ws, _, err := websocket.{}.Dial(url, nil) if err != nil { t.("dial: %v", err) }

Соберите порядок теста

Расставьте строки теста: подключение, дедлайн, отправка и чтение.

Строки

Ваша программа

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

Карточки: тесты ws

Термин

httptest.NewServer для ws

нажмите, чтобы перевернуть
Определение

Настоящий HTTP-сервер на случайном порту: Upgrader в его хендлере делает то же, что прод. Адрес — ts.Listener.Addr().String().

1 / 5

Тестовая обвязка готова: сервер на loopback, Dialer, дедлайны и таблица кейсов, которая растёт вместе с протоколом. Следующий вопрос — что делать, когда тесты зелёные, а живой сервер всё равно ведёт себя странно: на этот случай есть двадцать второй урок про отладку с wscat и curl. Эхо-сервер, который здесь стоял в хелпере, разобран в четвёртом уроке, конверты и их теги — в десятой теме про JSON-протокол, лимит кадра и код 1009 — в четырнадцатом уроке. Table-driven подход и t.Run подробно описаны в продвинутом курсе Go в теме про тестирование. Пачку из двадцатого урока тест ловит циклом чтений — один ReadMessage на конверт. Проверьте свой протокол на локальном Go: сотня кейсов проходит быстрее, чем открывается браузер. План курса — на странице курса WebSocket.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • httptest.NewServer поднимает настоящий сервер на случайном порту: Upgrader и CheckOrigin те же, что в проде.
  • Адрес для Dialer'а — ts.Listener.Addr().String(): Listener уже слушает, подключение мгновенно.
  • SetReadDeadline ставится в хелпере: зависший тест без дедлайна съедает десять минут таймаута go test.
  • Нарушение протокола проверяется кодом закрытия: 1009 за размер, 1007 за битый JSON, через errors.As в CloseError.
  • Table-driven кейсы получают свежий сервер через t.Cleanup: независимость стоит одного вызова хелпера.
  • Мок conn-интерфейса проверяет мок: рукопожатие, лимиты и пайпы честнее тестировать через настоящий loopback.

Домашнее задание

Соберите newEchoServer с Upgrader и t.Cleanup, подключитесь Dialer'ом и прогоните TestEcho с дедлайном чтения в две секунды. Затем добавьте table-driven TestProtocol с кейсами неизвестного типа и битого JSON, а кейс с кадром больше лимита проверьте через errors.As в websocket.CloseError на код 1009. Запустите go test -v локально и убедитесь, что весь набор проходит быстрее секунды.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Тесты: ws-клиент в httptest)

Вопросы и ответы по уроку

Почему Dialer, а не мок интерфейса соединения?

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

Как тестировать приватные соединения с авторизацией?

Токен передаётся третьим аргументом Dial: заголовок Authorization или query-параметр, как в тринадцатом уроке. Отказ авторизации проверяется на стороне Dialer'а: рукопожатие вернёт ошибку с кодом 401, и тест сравнивает код ответа. Для ролевых сценариев открывают два соединения с разными токенами и проверяют, что кадр из приватной комнаты уходит только адресату.

Нужен ли параллельный запуск t.Parallel()?

Тесты с собственным httptest-сервером на случайном порту изолированы, и t.Parallel() им не повредит. Но выигрыш появляется на сотнях кейсов: рукопожатие занимает доли миллисекунды, и до этой отметки параллельность добавляет шум в вывод. Сначала гоняйте последовательно, параллель включайте, когда набор растянется на секунды.

Термины урока

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

Следующая ступень учебного пути

RabbitMQ25 уроков~11 чпрактика25 уроков уже доступны

RabbitMQ в Go: очереди сообщений на amqp091-go

Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.

  • Подключаться к RabbitMQ из Go и управлять connection и channel
  • Объявлять очереди, обменники и привязки с нужными свойствами
  • Публиковать и потреблять сообщения с ручными ack
  • Настраивать prefetch и справедливое распределение между воркерами