Пока проверка чата выглядит как «открыл вкладку, написал сообщение, посмотрел глазами», каждый релиз собирает неявный долг: одно время я сопровождал сервис, где состояние комнаты проверяли тремя людьми вручную, и регрессия из двадцатого урока — пачка, в которой терялся последний конверт, — дожила до продакшена ровно потому, что глаза не заметили пропажу одного кадра из шести. Тест занял бы пятнадцать строк и поймал бы это на пуше. Хорошая новость в том, что 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
GOfunc 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) ставит две секунды и возвращает управление.
Первый тест: конверт уезжает и возвращается
GOfunc 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 big | errors.As в websocket.CloseError |
| Битый JSON | закрытие 1007 invalid payload | errors.As в websocket.CloseError |
| Просроченный токен в query | отказ рукопожатия | err от Dial: unexpected response code |
Table-driven кейс: нарушение протокола
GOfunc 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, как в таблице выше.
Реальная история из практики команды: один кейс читал ответ сервера, который при ошибке авторизации закрывал соединение без кадра. ReadMessage ждал вечно, go test -timeout резал пакет по десяти минутам, и в день перед демо пайплайн стоял четыре раза подряд. Лечение заняло одну строку — SetReadDeadline в хелпере, — но правильный вывод шире: дедлайн чтения ставится не в кейсе, а в общем хелпере, чтобы новый кейс физически не мог забыть о нём. Правило простое: если тест блокируется на чтении из сети, у этого чтения есть дедлайн, и он меньше таймаута go test на порядок.
Спорное: тесты через настоящую сеть#
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)Песочница курса умеет только fmt, strings, strconv, math, sort, errors и time — сети в ней нет, поэтому тесты websocket запускаются на локальном Go: go mod init, go get github.com/gorilla/websocket, go test -v -run TestEcho. Тест с эхо-сервером проходит за миллисекунды даже на ноутбуке. Дальше усложняйте: подмените эхо на хаб из восьмого урока и добавьте кейс с двумя Dialer'ами в одну комнату — это будет уже интеграционный тест чата целиком.
Соберите хелпер подключения
Впишите четыре пропуска: адрес Listener, конструктор Dialer, метод подключения и способ провалить тест.
Соберите порядок теста
Расставьте строки теста: подключение, дедлайн, отправка и чтение.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты ws
Тестовая обвязка готова: сервер на 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 локально и убедитесь, что весь набор проходит быстрее секунды.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Dialer, а не мок интерфейса соединения?
Мок повторяет текущее понимание библиотеки автором мока, и после обновления gorilla расхождение с реальностью остаётся незамеченным. Настоящий сокет на loopback стоит миллисекунд, тестирует рукопожатие, лимиты и коды закрытия так, как они произойдут в проде, и не требует поддержки. Мок остаётся для чистой сериализации, где сети нет по определению.
Как тестировать приватные соединения с авторизацией?
Токен передаётся третьим аргументом Dial: заголовок Authorization или query-параметр, как в тринадцатом уроке. Отказ авторизации проверяется на стороне Dialer'а: рукопожатие вернёт ошибку с кодом 401, и тест сравнивает код ответа. Для ролевых сценариев открывают два соединения с разными токенами и проверяют, что кадр из приватной комнаты уходит только адресату.
Нужен ли параллельный запуск t.Parallel()?
Тесты с собственным httptest-сервером на случайном порту изолированы, и t.Parallel() им не повредит. Но выигрыш появляется на сотнях кейсов: рукопожатие занимает доли миллисекунды, и до этой отметки параллельность добавляет шум в вывод. Сначала гоняйте последовательно, параллель включайте, когда набор растянется на секунды.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Отладка: wscat, curl и логи соединений
- Масштабирование: Redis Pub/Sub между инстансами
- Сценарии: уведомления, тикер, кооп-редактор
- Итоговый проект: чат с комнатами
- Батчинг: не спамим кадрами
- Presence: кто сейчас онлайн
- Отвалы: чистка мёртвых соединений
- Reconnect: клиент, который возвращается
- Все курсы по Go
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами