先に、動かないテストから。testing/synctest の bubble のなかで httptest.NewServer を立てて、クライアント側のタイムアウトを検証しようとしたコードです。
func TestOldNewServerInBubble(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
srv := httptest.NewServer(slowHandler()) // 30秒かけて返すハンドラ
defer srv.Close()
c := srv.Client()
c.Timeout = 5 * time.Second
start := time.Now()
_, err := c.Get(srv.URL)
t.Logf("err=%v bubble経過=%v", err, time.Since(start))
})
}
5秒でタイムアウトして終わるはずが、こうなります。
$ go1.27 test -run TestOldNewServerInBubble -v -timeout 20s .
=== RUN TestOldNewServerInBubble
panic: test timed out after 20s
running tests:
TestOldNewServerInBubble (20s)
goroutine 20 [synctest.Run (durable), synctest bubble 1]:
internal/synctest.Run(0x785ba6da43a0)
/Users/moha/sdk/go1.27rc2/src/runtime/synctest.go:214 +0x1a4
タイムアウトどころか、テスト自体が固まります。Go 1.27 の net/http/httptest に追加された NewTestServer は、この「synctest と HTTP テストが噛み合わない」問題への標準ライブラリ側の答えです。実ポートを一切開かない in-memory なネットワーク実装の上でサーバーを動かします。
検証環境は go1.27rc2 darwin/arm64(Apple M5 Pro)です。Go 1.27 は 2026年8月19日にリリースされました。
bubble に実ネットワークを持ち込むと時計が止まる
まず、なぜ固まるのかを押さえておかないと NewTestServer のありがたみが分かりません。原因は synctest の「時間の進め方」にあります。
durably blocked の定義から外れてしまう
testing/synctest のパッケージドキュメントには、Blocking というセクションで次のように書かれています。
A goroutine in a bubble is “durably blocked” when it is blocked and can only be unblocked by another goroutine in the same bubble. A goroutine which can be unblocked by an event from outside its bubble is not durably blocked.
bubble のなかの時計が進むのは、bubble 内の全 goroutine が durably blocked になったときだけです。ところが TCP ソケットの読み取りで止まっている goroutine は、bubble の外(OS のネットワークスタック)からのイベントで起きる可能性があります。つまり durably blocked とは見なされません。
結果として、こういう膠着が起きます。
- クライアント側の goroutine は実ソケットの応答待ちで止まる → durably blocked ではない
- bubble の仮想時計は「全員が durably blocked になるまで」進まない → 止まったまま
c.Timeout = 5 * time.Secondのタイマーは仮想時計に載っている → いつまでも発火しない- ハンドラ側の
time.Sleep(30 * time.Second)も同じ理由で明けない
誰も進まないまま go test のタイムアウトに突き当たる、というわけです。エラーメッセージが panic: test timed out だけなので、最初に踏んだときは自分のハンドラを疑ってしばらく時間を溶かしました。
「Use a fake network implementation」が指していたもの
同じドキュメントの冒頭には、テストが満たすべきガイドラインとして4項目が並んでいます。その2番目がこれです。
Avoid using the network. Use a fake network implementation as needed.
Go 1.25 で testing/synctest が正式パッケージになった時点では、この「fake network implementation」は各自で用意するものでした。net.Pipe を使って自前で net.Listener をでっち上げるか、http.RoundTripper をモックに差し替えるか。Go 1.27 の NewTestServer は、それを標準ライブラリが持ってくれたという話です。synctest そのものの挙動については testing/synctestでGoの並行処理テストを決定的にする方法 のほうで整理しています。
NewTestServer に置き換えると5秒が一瞬で終わる
さきほどのテストを NewTestServer で書き直します。
差分は実質2行
func TestNewTestServerInBubble(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
srv := httptest.NewTestServer(t, slowHandler()) // t を渡す。Close は不要
c := srv.Client()
c.Timeout = 5 * time.Second
start := time.Now()
_, err := c.Get("http://example.com/")
t.Logf("err=%v bubble経過=%v", err, time.Since(start))
})
}
シグネチャは func NewTestServer(t testing.TB, handler http.Handler) *Server で、第1引数に testing.TB を取るのが NewServer との一番の見た目の違いです。defer srv.Close() が消えているのは書き忘れではなく、公式ドキュメントの Tests セクションに挙動が明記されています。
Register a Cleanup function to shut down the server at the end of the test.
t.Cleanup が中で登録されるので、閉じ忘れが構造的に起きません。testing.TB なので *testing.B も渡せます。
仮想時間で5秒、実時間で0.00秒
$ time go1.27 test -run TestNewTestServerInBubble -v -timeout 30s .
=== RUN TestNewTestServerInBubble
synctest_test.go:40: err=Get "http://example.com/": context deadline exceeded bubble経過=5s
--- PASS: TestNewTestServerInBubble (0.00s)
PASS
ok example.com/httptestlab 0.268s
go1.27 test ... 0.23s user 0.40s system 134% cpu 0.468 total
bubble経過=5s と出ているのに、テスト本体の実測は 0.00s、go test 全体でも 0.268 秒です。仮想時計の上では確かに5秒が流れてタイムアウトが発火し、実時間はまったく消費していません。リトライ間隔やサーキットブレーカーの復帰時間みたいな「分単位で待つ挙動」も、これなら CI で普通にテストできます。
Go 1.27 で synctest.Sleep が足された理由
ついでに testing/synctest 側にも Go 1.27 で Sleep が入りました。ドキュメントいわく、これは次と厳密に等価です。
time.Sleep(d)
synctest.Wait()
サジェストで「go synctest wait」がよく出てくるあたり、この Wait の要否は皆つまずくところなんだと思います。テスト側とテスト対象が同じ長さだけ眠ったとき、どちらが先に起きるかは決まりません。Sleep は眠ったあとにテスト対象が落ち着くのを待ってくれるので、time.Sleep 単体より意図が明確になります。
in-memory network が実ポートの何を置き換えるのか
公式ドキュメントの In-Memory Network セクションは、こう言い切っています。
Most tests should use the in-memory network, which avoids port exhaustion and other transient networking issues and is suitable for use with the testing/synctest package.
synctest 対応は理由の一つでしかなくて、ポート枯渇の回避のほうが先に挙がっています。
Listener は nil、URL は http://example.com
in-memory モードのサーバーがどう見えるかを出力してみます。
func TestURLTiming(t *testing.T) {
srv := httptest.NewTestServer(t, handler())
t.Logf("Client() 呼び出し前: URL=%q", srv.URL)
_ = srv.Client()
t.Logf("Client() 呼び出し後: URL=%q", srv.URL)
t.Logf("Listener=%v", srv.Listener)
}
=== RUN TestURLTiming
url_test.go:10: Client() 呼び出し前: URL=""
url_test.go:12: Client() 呼び出し後: URL="http://example.com"
url_test.go:13: Listener=<nil>
--- PASS: TestURLTiming (0.00s)
Server.Listener は nil です。実際のリスナーが存在しないので当然ですが、既存テストで srv.Listener.Addr() を触っているとここで nil パニックします。Server.URL のほうも注意が要って、Client() を呼ぶまで空文字列でした。構造体のコメントに「It is set by the first call to Client, Start, or StartTLS」とある通りの挙動です。
ulimit を256に絞ると83台目で落ちる
ポート枯渇の話は抽象的になりがちなので、ファイルディスクリプタを絞って実際に壊してみました。同じテストファイルに、300台のサーバーを順に立てて1リクエストずつ投げるテストを2種類置きます。
const n = 300
func TestManyLoopbackServers(t *testing.T) {
for i := 0; i < n; i++ {
srv := httptest.NewServer(pingHandler())
t.Cleanup(srv.Close)
if _, err := srv.Client().Get(srv.URL); err != nil {
t.Fatalf("%d台目で失敗: %v", i, err)
}
}
t.Logf("ループバック %d台 OK", n)
}
func TestManyInMemoryServers(t *testing.T) {
for i := 0; i < n; i++ {
srv := httptest.NewTestServer(t, pingHandler())
if _, err := srv.Client().Get("http://example.com/"); err != nil {
t.Fatalf("%d台目で失敗: %v", i, err)
}
}
t.Logf("in-memory %d台 OK", n)
}
$ ulimit -n 256; go1.27 test -run "TestMany" -v -timeout 60s .
=== RUN TestManyLoopbackServers
scale_test.go:16: 83台目で失敗: Get "http://127.0.0.1:62395": EOF
2026/08/21 20:09:49 http: Accept error: accept tcp 127.0.0.1:62395: accept: too many open files; retrying in 5ms
--- FAIL: TestManyLoopbackServers (0.03s)
=== RUN TestManyInMemoryServers
scale_test.go:29: in-memory 300台 OK
--- PASS: TestManyInMemoryServers (0.06s)
ループバック版は83台目で too many open files に当たりました。1台あたりリスナー・サーバー側コネクション・クライアント側コネクションで3つ食うので、256 の枠だとこのあたりが限界です。in-memory 版は300台を0.06秒で通します。t.Parallel() を効かせた大きめのテストスイートで、CI だけたまに落ちるやつの正体がこれだったりします。
HTTP/2 も TLS も in-memory のまま通る
「実ポートがない」と聞くと機能が削られていそうですが、EnableHTTP2 はそのまま効きます。
func TestHTTP2InMemory(t *testing.T) {
srv := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(r.Proto))
}))
srv.EnableHTTP2 = true // Client()/Start()/StartTLS() より前に設定する
resp, err := srv.Client().Get("https://example.com/")
// ...
}
=== RUN TestHTTP2InMemory
scale_test.go:44: proto=HTTP/2.0
--- PASS: TestHTTP2InMemory (0.01s)
https:// で叩けば TLS ハンドシェイクも in-memory なコネクションの上で行われ、ハンドラ側の r.TLS にもきちんと値が入ります。証明書の準備もクライアントへの CA 登録も不要です。
どのURLを叩いてもテストサーバーに届く
ここが一番効きました。ドキュメントの In-Memory Network セクションには、こう書かれています。
The client will direct HTTP and HTTPS requests, regardless of destination address or hostname, to the server. Requests do not need to use Server.URL as the base URL.
Server.Client() が返す *http.Client は、宛先が何であろうと全部テストサーバーに向けます。最初に読んだときは大げさな表現だと思ったので、実際に確かめました。
basic_test.go:34: GET http://www.example.com/a -> host=www.example.com path=/a proto=HTTP/1.1 tls=false
basic_test.go:34: GET https://go.dev/b -> host=go.dev path=/b proto=HTTP/1.1 tls=true
basic_test.go:34: GET http://10.0.0.1/c -> host=10.0.0.1 path=/c proto=HTTP/1.1 tls=false
https://go.dev/ 宛のリクエストまでテストサーバーが受けています。ハンドラ側では r.Host にちゃんと go.dev が入るので、宛先ごとに応答を変えることもできます。
ベースURLを差し替えられる設計にしなくてよくなる
外部 API を叩くクライアントをテストするとき、これまでは「ベース URL をフィールドに持たせて注入できるようにする」のが定石でした。テストのためだけに baseURL string を生やした経験は、たぶん多くの人にあると思います。
// テスト対象。エンドポイントはハードコードのまま
type WeatherClient struct {
hc *http.Client
}
func (c *WeatherClient) Get(city string) (*Weather, error) {
resp, err := c.hc.Get("https://api.example-weather.com/v1/current?city=" + city)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var w Weather
if err := json.UnmarshalRead(resp.Body, &w); err != nil { // encoding/json/v2
return nil, err
}
return &w, nil
}
func TestWeatherClient(t *testing.T) {
srv := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("サーバーが受けたURL: https://%s%s", r.Host, r.URL.RequestURI())
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"city":"Tokyo","temp":34}`)
}))
c := &WeatherClient{hc: srv.Client()} // 差し替えるのは http.Client だけ
got, err := c.Get("Tokyo")
if err != nil {
t.Fatal(err)
}
t.Logf("got=%+v", *got)
}
=== RUN TestWeatherClient
client_test.go:39: サーバーが受けたURL: https://api.example-weather.com/v1/current?city=Tokyo
client_test.go:49: got={City:Tokyo Temp:34}
--- PASS: TestWeatherClient (0.01s)
エンドポイントはコード中にハードコードされたままで、注入したのは *http.Client ひとつだけです。しかもハンドラ側に本来のパスとクエリがそのまま届くので、リクエストの組み立て方まで含めて検証できるのが地味に大きいところ。URL の組み立てをテストしたいのに、テストのために URL を差し替えている、という本末転倒が消えます。GoのインターフェースとDI設計パターン—テスタブルなコードの作り方 で書いたような注入の設計も、HTTP まわりに関しては一段軽くできます。
srv.URL を http.DefaultClient に渡すと本物に飛ぶ
逆に、これは踏むと厄介です。in-memory モードの srv.URL は http://example.com なので、うっかり http.DefaultClient や http.Get に渡すと 本物の example.com にリクエストが出ていきます。
func TestDefaultClientDoesNotReach(t *testing.T) {
srv := httptest.NewTestServer(t, pingHandler())
_ = srv.Client() // URL を確定させる
_, err := http.Get(srv.URL + "/")
t.Logf("http.DefaultClient で %s を叩いた結果: %v", srv.URL, err)
}
=== RUN TestDefaultClientDoesNotReach
client_test.go:57: http.DefaultClient で http://example.com を叩いた結果: <nil>
--- PASS: TestDefaultClientDoesNotReach (0.12s)
エラーは <nil>、テストは PASS。でもテストサーバーには1リクエストも届いていません。従来の NewServer なら srv.URL が http://127.0.0.1:62204 のような実アドレスなので http.DefaultClient でも通じていました。移行時にここだけ書き換え漏れがあると、静かに外部へ出ていくテストができあがります。宛先が実在するドメインだった場合、CI から本番 API を叩くことになりかねません。in-memory モードでは srv.Client() 以外のクライアントを使わない、と決めておくのが安全です。
NewServer・NewTLSServer・NewRecorderとの使い分け
「httptest newserver」「httptest newrecorder」あたりは検索サジェストの常連ですが、Go 1.27 で選択肢が1つ増えたので整理しておきます。
4つの生成方法の比較
| 生成方法 | 実ポート | Close | synctest | 通る層 | 主な用途 |
|---|---|---|---|---|---|
NewTestServer(t, h) | 開かない | 自動(t.Cleanup) | 使える | HTTPスタック全体 | Go 1.27以降の既定 |
NewServer(h) | 開く | 手動 | 固まる | HTTPスタック全体 | 実アドレスが要るとき |
NewTLSServer(h) | 開く | 手動 | 固まる | HTTPスタック全体+TLS | 実TLSが要るとき |
NewRecorder() + NewRequest() | 開かない | 不要 | 使える | ハンドラのみ | ハンドラ単体テスト |
NewTestServer でも Start() か StartTLS() を呼べばループバックに切り替わるので、NewServer 相当の動きは NewTestServer 側から取り出せます。逆はできません。
ラウンドトリップのコストを測る
どのくらい速いのかは測らないと分からないので、同じハンドラに対して3通りのやり方でベンチを取りました。計測は b.Loop を使っています。
func BenchmarkInMemory(b *testing.B) {
srv := httptest.NewTestServer(b, pingHandler())
c := srv.Client()
b.ReportAllocs()
for b.Loop() {
resp, err := c.Get("http://example.com/")
if err != nil {
b.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
$ go1.27 test -bench 'Benchmark(Loopback|InMemory|Recorder)$' -benchtime 3s -run XXX -cpu 1 .
goos: darwin
goarch: arm64
pkg: example.com/httptestlab
cpu: Apple M5 Pro
BenchmarkLoopback 130899 25242 ns/op 5041 B/op 62 allocs/op
BenchmarkInMemory 732810 4749 ns/op 5038 B/op 62 allocs/op
BenchmarkRecorder 3784980 949.0 ns/op 6148 B/op 19 allocs/op
PASS
ループバック 25242 ns/op に対して in-memory は 4749 ns/op で、約5.3倍速いという結果でした。面白いのはアロケーションで、ループバックと in-memory はどちらも 62 allocs/op とほぼ同じです。HTTP のパースやヘッダー処理はまったく同じコードを通っていて、差分はカーネルとのやり取りの分だけ、ということがはっきり出ています。ベンチの取り方については Goのb.Loopで正確なベンチマークを書く—b.Nとの違いと落とし穴 にまとめてあります。
NewRequest と NewRecorder が不要になるわけではない
NewRecorder の 949 ns/op は突出して速いですが、これはサーバーもクライアントも介さずハンドラを直接呼んでいるからです。Transfer-Encoding の扱いやコネクション再利用、ミドルウェアのチェーンといった「HTTP サーバーとしての挙動」は検証できません。使い分けはシンプルで、ハンドラ1個のロジックを確かめたいなら httptest.NewRequest と NewRecorder、クライアントを含む往復を確かめたいなら NewTestServer。前者が要らなくなることはないです。
置き換えでひっかかったところ
既存テストを機械的に NewServer → NewTestServer に置き換えると、いくつか引っかかります。
Start() を呼んだ瞬間ループバックに戻る
NewTestServer は「in-memory がデフォルト」なだけで、Start() を呼べば従来どおりポートを開きます。
url_test.go:19: Start()後: URL="http://127.0.0.1:62204"
url_test.go:20: Listener.Addr()=127.0.0.1:62204
ドキュメントの In-Memory Network セクションに「Do not call Server.Start or Server.StartTLS」と明記されています。NewUnstartedServer からの移行で Start() をそのまま残していると、名前だけ新しくなって中身はループバックのまま、という状態になります。
Cleanup の Close がハンドラを待つ
最初の slowHandler の例、実は PASS しつつ警告を吐いていました。
=== RUN TestNewTestServerInBubble
synctest_test.go:40: err=Get "http://example.com/": context deadline exceeded bubble経過=5s
2000/01/01 09:00:10 httptest.Server blocked in Close after 5 seconds, waiting for connections:
*nettest.Conn 0x6dc111ba04c8 127.0.0.1:10001 in state active
--- PASS: TestNewTestServerInBubble (0.00s)
クライアントは5秒で諦めたのに、ハンドラの time.Sleep(30 * time.Second) はまだ明けていない。そこへ t.Cleanup の Close が来て「5秒待ったがコネクションが残っている」と警告しています。タイムスタンプが 2000/01/01 なのは bubble の仮想時計が 2000-01-01 00:00 UTC 始まりだからで、ログ出力までまるごと仮想時間に載っていることが分かります。*nettest.Conn という型名と 127.0.0.1:10001 という擬似アドレスも、in-memory 実装の中身が透けていて面白いところです。
直し方はハンドラ側でリクエストのキャンセルを見ることです。
func cancelAwareHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(30 * time.Second):
w.Write([]byte("late"))
case <-r.Context().Done(): // クライアントが切ったら即座に降りる
return
}
})
}
=== RUN TestCancelAware
fix_test.go:30: err=Get "http://example.com/": context deadline exceeded bubble経過=5s
--- PASS: TestCancelAware (0.00s)
警告が消えました。この警告は「クライアントが切ってもハンドラが居座り続ける」という本番でも困る挙動を、テスト側が拾ってくれているとも読めます。サーバー停止とハンドラの後始末の関係は Go graceful shutdown実測—Shutdownの盲点と500msの遅れ でも同じ根が出てきます。
ハンドラの panic がテストの失敗になる
もう1つ、Tests セクションに書かれている挙動です。
Fail the test if the server handler panics with any value other than http.ErrAbortHandler.
実際にハンドラで panic("boom") してみると、こうなります。
server.go:202: httptest: panic in server handler: boom
goroutine 33 [running]:
net/http/httptest.testServerHandler.ServeHTTP.func1()
/Users/moha/sdk/go1.27rc2/src/net/http/httptest/server.go:201 +0x84
...
fix_test.go:40: resp=<nil> err=Get "http://example.com/": EOF
FAIL
従来の NewServer だと、net/http のサーバーが panic を回収してコネクションを切るだけなので、テスト側には EOF しか見えませんでした。NewTestServer は EOF に加えてスタックトレース付きでテストを FAIL させます。原因がその場で分かるのは、地味ですが移行して一番うれしかった変化かもしれません。
まとめ
httptest.NewTestServer(t, handler)は Go 1.27 追加。実ポートを開かない in-memory network がデフォルトで、t.Cleanupによる停止も自動登録されるtesting/synctestの bubble 内でNewServerを使うと仮想時計が進まずテストが固まる。NewTestServerなら仮想時間で完結し、5秒のタイムアウト検証が実時間 0.00 秒で終わる- ファイルディスクリプタを 256 に絞った環境で、ループバックは83台目で
too many open files、in-memory は300台とも成功 Server.Client()は宛先に関係なく全リクエストをテストサーバーへ送る。ベース URL を注入可能にする設計なしで、ハードコードされたエンドポイントごと検証できる- 逆に
srv.URL(in-memory ではhttp://example.com)をhttp.DefaultClientに渡すと本物へ出ていく。エラーにならないぶん気づきにくい - ラウンドトリップは 4749 ns/op で、ループバックの 25242 ns/op より約5.3倍速い。allocs/op は 62 で同じなので、差はカーネルとのやり取り分
- ハンドラの panic はスタックトレース付きでテストを FAIL させる。
Start()を呼ぶとループバックに戻る点だけ注意
Go 1.26 以前を使っているならこの関数はまだ使えませんが、synctest 自体は Go 1.25 から正式に入っています。バージョンを上げられるタイミングで、まず NewServer の一括置換から始めるのが手っ取り早いと思います。