---
title: "Тесты: ws-клиент в httptest"
description: "Тесты websocket go через Dialer и httptest.NewServer: чтение и запись с дедлайном, t.Cleanup, table-driven кейсы протокола. Чат в тесте за миллисекунды."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/21-testy-ws/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 21 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/21-testy-ws/*

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

Пока проверка чата выглядит как «открыл вкладку, написал сообщение, посмотрел глазами», каждый релиз собирает неявный долг: одно время я сопровождал сервис, где состояние комнаты проверяли тремя людьми вручную, и регрессия из двадцатого урока — пачка, в которой терялся последний конверт, — дожила до продакшена ровно потому, что глаза не заметили пропажу одного кадра из шести. Тест занял бы пятнадцать строк и поймал бы это на пуше. Хорошая новость в том, что 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 big | errors.As в websocket.CloseError |
| Битый JSON | закрытие 1007 invalid payload | errors.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, как в таблице выше.

> **Тест без дедлайна заморозил пайплайн.** Реальная история из практики команды: один кейс читал ответ сервера, который при ошибке авторизации закрывал соединение без кадра. ReadMessage ждал вечно, go test -timeout резал пакет по десяти минутам, и в день перед демо пайплайн стоял четыре раза подряд. Лечение заняло одну строку — SetReadDeadline в хелпере, — но правильный вывод шире: дедлайн чтения ставится не в кейсе, а в общем хелпере, чтобы новый кейс физически не мог забыть о нём. Правило простое: если тест блокируется на чтении из сети, у этого чтения есть дедлайн, и он меньше таймаута go test на порядок.

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

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

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

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

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

**Вопрос:** Откуда тест берёт адрес для Dialer'а?

- ✅ Из ts.Listener.Addr().String(): Listener уже слушает случайный порт
- Из константы localhost:8080, которую сервер обязан занять
- Dialer сам находит порт по названию теста
- Из переменной окружения, которую задаёт go test

httptest.NewServer выбирает свободный порт и уже слушает его: адрес читается из Listener, и Dialer подключается мгновенно. Фиксированный 8080 превратил бы параллельные тесты в лотерею, а поиск свободного порта вручную дублирует то, что httptest делает сам.

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

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

- ReadMessage вернёт нулевую ошибку и пустой кадр
- ReadMessage вернёт ошибку сразу после рукопожатия
- ✅ Тест повиснет до глобального таймаута go test
- Тест завершится, потому что dial закрыл соединение

Чтение из открытого сокета — блокирующая операция: без дедлайна горутина теста висит, пока go test не убьёт пакет по таймауту, десять минут по умолчанию. Дедлайн в одну-две секунды переводит зависание в быстрый провал с сообщением i/o timeout и указанием кейса.

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

```go
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)
```

**Что выведет программа?**

- первыйвторой true: оба кадра склеились в одну запись
- ✅ первый true: один ReadMessage читает один кадр
- второй true: чтение берёт последний кадр из очереди
- false: два кадра подряд приводят к ошибке протокола

Кадры не склеиваются и не замещают друг друга: каждый ReadMessage забирает ровно один кадр в порядке отправки. После первого чтения в сокете остаётся второй, и следующий ReadMessage вернёт его. Именно поэтому тест, проверяющий пачку из двадцатого урока, читает столько раз, сколько конвертов отправлено.

> **Прогоните тесты на локальном Go.** Песочница курса умеет только 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, метод подключения и способ провалить тест.

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

Подсказка: Адрес собирается из полей тестового сервера: Listener и его Addr. Подключается пустой Dialer методом Dial, а ошибка проваливает тест через Fatalf.

Правильные ответы в пропусках: `Listener`, `Addr`, `Dialer`, `Fatalf`

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

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

Правильный порядок строк:

1. `ws := newEchoServer(t)`
2. `ws.SetReadDeadline(time.Now().Add(2 * time.Second))`
3. `ws.WriteMessage(websocket.TextMessage, []byte("ping"))`
4. `_, msg, err := ws.ReadMessage()`
5. `if err != nil {`
6. `t.Fatalf("read: %v", err)`

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

- **httptest.NewServer для ws** — Настоящий HTTP-сервер на случайном порту: Upgrader в его хендлере делает то же, что прод. Адрес — ts.Listener.Addr().String().
- **websocket.Dialer{}.Dial** — Клиентская сторона теста: ws, response, err. Третий аргумент — заголовки запроса, там живёт токен для авторизации.
- **SetReadDeadline в тесте** — Дедлайн чтения в одна-две секунды: без него зависший тест съедает таймаут go test. Ставится в общем хелпере, а не в кейсе.
- **CloseError и errors.As** — Нарушение протокола приходит закрытием: код 1009 — кадр больше лимита, 1007 — битый JSON. Коды стабильны, тексты ошибок — нет.
- **t.Cleanup против defer** — Cleanup собирает закрытие сервера и клиента в хелпере: каждый кейс получает независимое окружение без повторения defer.

Тестовая обвязка готова: сервер на loopback, Dialer, дедлайны и таблица кейсов, которая растёт вместе с протоколом. Следующий вопрос — что делать, когда тесты зелёные, а живой сервер всё равно ведёт себя странно: на этот случай есть [двадцать второй урок про отладку](/courses/websocket/lessons/22-otladka-ws/) с wscat и curl. Эхо-сервер, который здесь стоял в хелпере, разобран в [четвёртом уроке](/courses/websocket/lessons/04-pervyj-server/), конверты и их теги — в [десятой теме про JSON-протокол](/courses/websocket/lessons/10-json-soobscheniya/), лимит кадра и код 1009 — в [четырнадцатом уроке](/courses/websocket/lessons/14-limity/). Table-driven подход и t.Run подробно описаны в [продвинутом курсе Go](/courses/go-advanced/) в [теме про тестирование](/courses/go-advanced/lessons/16-testirovanie/). Пачку из [двадцатого урока](/courses/websocket/lessons/20-batching/) тест ловит циклом чтений — один ReadMessage на конверт. Проверьте свой протокол на локальном Go: сотня кейсов проходит быстрее, чем открывается браузер. План курса — на странице [курса WebSocket](/courses/websocket/).

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

- 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() им не повредит. Но выигрыш появляется на сотнях кейсов: рукопожатие занимает доли миллисекунды, и до этой отметки параллельность добавляет шум в вывод. Сначала гоняйте последовательно, параллель включайте, когда набор растянется на секунды.
