Testing in Go: the testing Package, Table-Driven Tests, httptest, Benchmarks and synctest
Testing in Go needs no framework: _test.go files, table-driven tests with t.Run, hand-written fakes that satisfy an interface, and httptest are enough. In Bookstore: a table test for the stock rule, a fixed clock, handler tests through the mux, synctest with Go 1.27's httptest.NewTestServer, a fuzz test that finds a negative amount bug, a benchmark of generic code, an integration test against SQL Server, coverage and go test -race.
Version note: The code and tests in this post were built and run with Go 1.27.4; the module's go line is 1.26.0 and the tests also pass with Go 1.26.11. httptest.NewTestServer arrived in Go 1.27, so the test file that uses it is set apart with //go:build go1.27. testing.B.Loop and T.Context arrived in Go 1.24, testing/synctest in Go 1.25. The integration test was run with SQL Server 2025 and go-mssqldb v1.11.2.
Guess First
The price type of Bookstore, the online bookstore used as the shared example across this site's series, has held kuruş as an int64 since post 03 and printed itself like this:
// String formats the amount for people: Money(34990) prints as "349.90 TRY".
func (m Money) String() string {
return fmt.Sprintf("%d.%02d TRY", m/100, m%100)
}
fmt.Println(Money(34990), Money(-150), Money(-5))
With a discounted order, a refund amount can be negative. What does the last line print?
349.90 TRY -1.-50 TRY 0.-5 TRY
Integer division in Go rounds toward zero, and % takes the sign of the dividend: -150/100 is -1, -150%100 is -50. This bug has been in the code since post 03. I did not find it; a fuzz test we will write near the end of this post found it in 0.1 seconds. In this post we look at Go's testing package: table-driven tests, fakes without a mock library, httptest, synctest with Go 1.27's httptest.NewTestServer, fuzzing, benchmarks and coverage. So far we ran every behaviour by hand and looked at its output; now we make that repeatable.
The testing Package and Table-Driven Tests
In Go you do not install a separate test framework. Files whose names end in _test.go are compiled only during go test; every function with the signature func TestXxx(t *testing.T) is a test. There is no assertion library either: you write the condition with if and report failures with t.Errorf or t.Fatalf. The first lets the test continue, the second ends that test right there.
The counterpart of xUnit's [Theory] + [InlineData] is the table-driven test: inputs and expected results go into a slice, and the test is a single loop over that slice. This is how I test the stock rule that has lived in Service.Place since post 05:
func TestPlaceStockRule(t *testing.T) {
tests := []struct {
name string
isbn string
qty int
wantErr error // nil: the order must be placed
}{
{"less than stock", "9780441172719", 1, nil},
{"exactly the stock", "9780441172719", 2, nil},
{"one more than stock", "9780441172719", 3, ErrOutOfStock},
{"sold out", "9780547928227", 1, ErrOutOfStock},
{"unknown isbn", "9780000000000", 1, catalog.ErrBookNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
repo := &fakeRepo{}
_, err := newTestService(t, repo).Place(t.Context(), "ada", []Line{{ISBN: tt.isbn, Quantity: tt.qty}})
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Place(%s x%d) error = %v, want %v", tt.isbn, tt.qty, err, tt.wantErr)
}
if wantSaved := tt.wantErr == nil; (len(repo.saved) == 1) != wantSaved {
t.Errorf("saved %d orders, want saved = %v", len(repo.saved), wantSaved)
}
})
}
}
t.Run turns every row into a named subtest; go test -run 'TestPlaceStockRule/sold_out' runs a single row. t.Parallel() runs the subtests at the same time. Before Go 1.22 this loop needed a tt := tt line, otherwise all parallel subtests would see the last row; the loop variable change we saw in post 03 made that line unnecessary. t.Context() (Go 1.24) gives a context that is cancelled when the test ends. Since errors.Is(err, nil) returns true when err is nil, the "no error expected" rows go through the same check.
$ go test ./internal/orders -run TestPlaceStockRule -v
=== RUN TestPlaceStockRule
=== RUN TestPlaceStockRule/less_than_stock
=== PAUSE TestPlaceStockRule/less_than_stock
...
=== CONT TestPlaceStockRule/unknown_isbn
--- PASS: TestPlaceStockRule (0.00s)
--- PASS: TestPlaceStockRule/less_than_stock (0.00s)
--- PASS: TestPlaceStockRule/sold_out (0.00s)
--- PASS: TestPlaceStockRule/one_more_than_stock (0.00s)
--- PASS: TestPlaceStockRule/exactly_the_stock (0.00s)
--- PASS: TestPlaceStockRule/unknown_isbn (0.00s)
PASS
ok bookstore/internal/orders 0.470s
To see what the table is good for, I broke the rule on purpose: <= instead of b.Stock < l.Quantity. This off-by-one bug, which can never sell the last copy in stock, is easy to miss when trying things by hand:
--- FAIL: TestPlaceStockRule (0.00s)
--- FAIL: TestPlaceStockRule/exactly_the_stock (0.00s)
service_test.go:79: Place(9780441172719 x2) error = place order: "Dune" has 2 in stock, 2 requested: out of stock, want <nil>
--- FAIL: TestPlaceUsesClockAndStoreID (0.00s)
service_test.go:92: place order: "Dune" has 2 in stock, 2 requested: out of stock
FAIL
The message says what was tried, what came back and what was expected: Place(input) = result, want expected. This shape, also used in Go's own tests, does the job of an assertion library's message. The anonymous struct slice is Go's counterpart of TheoryData from the Unit Test series' "xUnit v3'te Veri Odaklı Testler" post.
Fakes and a Fixed Clock
The Service in the test goes neither to the database nor to the real catalog. In post 04 we declared the Repository interface next to the service; now we get the payoff. Two small hand-written types in the test file are enough:
// fakeRepo is a hand-written Repository for tests: no mock library, just a
// struct that remembers what it was given.
type fakeRepo struct {
saved []*Order
saveErr error
}
func (f *fakeRepo) NextID(context.Context) (string, error) {
return fmt.Sprintf("ORD-%d", 1001+len(f.saved)), nil
}
func (f *fakeRepo) Save(_ context.Context, o *Order) error {
if f.saveErr != nil {
return f.saveErr
}
f.saved = append(f.saved, o)
return nil
}
func (f *fakeRepo) ByID(context.Context, string) (*Order, error) {
return nil, ErrNotFound
}
// fakeCatalog serves a fixed set of books.
type fakeCatalog map[string]catalog.Book
func (c fakeCatalog) ByISBN(isbn string) (catalog.Book, error) {
b, ok := c[isbn]
if !ok {
return catalog.Book{}, fmt.Errorf("isbn %s: %w", isbn, catalog.ErrBookNotFound)
}
return b, nil
}
var testBooks = fakeCatalog{
"9780441172719": {ISBN: "9780441172719", Title: "Dune", Price: 34990, Stock: 2},
"9780547928227": {ISBN: "9780547928227", Title: "The Hobbit", Price: 28950, Stock: 0},
}
// newTestService returns a Service with a fake store and a fixed clock.
func newTestService(t *testing.T, repo *fakeRepo) *Service {
t.Helper()
s := NewService(repo, testBooks)
s.now = func() time.Time { return time.Date(2026, 11, 15, 9, 30, 0, 0, time.UTC) }
return s
}
No library like Moq or NSubstitute, and no code generation. Because interfaces in Go are implicit, fakeRepo satisfies Repository without naming it; the interface has three methods, so the fake is fifteen lines. In the terms of the Unit Test series' "Test Double'lar" post, fakeRepo is both a fake and a spy: it really does the work, and it remembers what was saved. gomock and testify/mock are also used in the Go community, but as long as the interface stays small a hand-written fake is easier to read and breaks less during refactoring.
The fixed clock needed no separate abstraction. Service has had a now func() time.Time field since post 05, and because the test file is in package orders it can reach that unexported field. t.Helper() in newTestService makes a failure in this helper report the line of the test that called it, not the helper's own line. With a fixed clock we can test the order time with exact equality:
func TestPlaceUsesClockAndStoreID(t *testing.T) {
repo := &fakeRepo{}
o, err := newTestService(t, repo).Place(t.Context(), "ada", []Line{{ISBN: "9780441172719", Quantity: 2}})
if err != nil {
t.Fatal(err)
}
want := time.Date(2026, 11, 15, 9, 30, 0, 0, time.UTC)
if o.ID != "ORD-1001" || !o.CreatedAt.Equal(want) || o.Total() != 69980 {
t.Errorf("got %s created %v total %v; want ORD-1001 created %v total 699.80 TRY", o.ID, o.CreatedAt, o.Total(), want)
}
}
I used Equal instead of == here; a time.Time also carries location information and a monotonic clock reading, so two identical instants can differ under ==. Post 16 covers this separately.
A test file can take one of two package names. package orders (an internal test) reaches everything unexported; package orders_test (an external test) sees the package from outside, through its exported API only. I used the second for the handler tests: the test builds an httpapi.API the way main does.
Handler Tests: httptest
net/http/httptest gives two tools. httptest.NewRecorder is an http.ResponseWriter; you call the handler without starting a server and read the status, headers and body. It is similar to testing a handler with DefaultHttpContext in ASP.NET Core:
func TestGetBook(t *testing.T) {
routes := newAPI().Routes() // the mux fills r.PathValue("isbn")
tests := []struct {
path string
wantStatus int
wantBody string
}{
{"/api/books/9780441172719", http.StatusOK, `"title":"Dune"`},
{"/api/books/9780000000000", http.StatusNotFound, "book not found"},
{"/api/books/", http.StatusNotFound, "404 page not found"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
rec := httptest.NewRecorder()
routes.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
if rec.Code != tt.wantStatus || !strings.Contains(rec.Body.String(), tt.wantBody) {
t.Errorf("GET %s = %d %q, want %d containing %q", tt.path, rec.Code, rec.Body, tt.wantStatus, tt.wantBody)
}
})
}
}
The request goes to the mux, not straight to the handler, because, as we saw in post 09, pattern matching is what fills r.PathValue("isbn"). Call the handler on its own and PathValue returns an empty string. That is why the last row of the table is useful: /api/books/ matches no pattern, and the 404 comes from the mux, not from the handler.
The second tool is httptest.NewServer: it starts a real server on a random port on 127.0.0.1. Use it when you need an end-to-end request with the middleware chain, headers and body:
func TestPlaceOrderEndToEnd(t *testing.T) {
srv := httptest.NewServer(httpapi.Chain(newAPI().Routes(), httpapi.RequestID))
defer srv.Close()
body, err := os.Open("testdata/place_order.json") // go test runs in the package folder
if err != nil {
t.Fatal(err)
}
defer body.Close()
resp, err := srv.Client().Post(srv.URL+"/api/orders", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got struct {
ID string `json:"id"`
Total int64 `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusCreated || got.ID != "ORD-1001" || got.Total != 69980 {
t.Errorf("got %d %+v, want 201 ORD-1001 total 69980", resp.StatusCode, got)
}
if loc := resp.Header.Get("Location"); loc != "/api/orders/ORD-1001" {
t.Errorf("Location = %q", loc)
}
if resp.Header.Get("X-Request-ID") == "" {
t.Error("X-Request-ID header is missing")
}
}
The request body is in testdata/place_order.json. The go tool does not treat folders named testdata as packages, and go test runs the test in the package's folder, so the relative path is right on every machine. This rule replaces the project setting you use in xUnit to copy files to the output folder.
synctest and httptest.NewTestServer (Go 1.27)
In post 08 we tested the payment timeout with testing/synctest: code inside the bubble sees a fake clock, and time jumps forward once every goroutine is waiting. This was hard with HTTP, because httptest.NewServer opens a real network socket, and waiting on a socket is something the bubble does not know about. Go 1.27's httptest.NewTestServer uses an in-memory network by default, and its documentation says this works with synctest. It also registers the Cleanup that shuts the server down when the test ends. This is how I tested that the Logging middleware writes the duration correctly:
// TestLoggingDuration runs a slow handler inside a synctest bubble. The fake
// clock makes the logged duration exact, and NewTestServer (Go 1.27) serves
// over an in-memory network that works inside the bubble.
func TestLoggingDuration(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
log.SetFlags(0)
t.Cleanup(func() { log.SetOutput(os.Stderr); log.SetFlags(log.LstdFlags) })
synctest.Test(t, func(t *testing.T) {
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second) // returns at once: only the fake clock moves
w.Write([]byte("ok\n"))
})
srv := httptest.NewTestServer(t, httpapi.Chain(slow, httpapi.RequestID, httpapi.Logging))
req, _ := http.NewRequest(http.MethodGet, "http://bookstore.test/slow", nil)
req.Header.Set("X-Request-ID", "test-rid")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
synctest.Wait() // let the Logging middleware finish its line
})
if got, want := strings.TrimSpace(buf.String()), "GET /slow 200 3B 2s rid=test-rid"; got != want {
t.Errorf("log line = %q, want %q", got, want)
}
}
$ go test ./internal/httpapi -run TestLoggingDuration -v
=== RUN TestLoggingDuration
--- PASS: TestLoggingDuration (0.00s)
PASS
The handler sleeps for two seconds, the test finishes in 0.00 seconds, and the duration in the log line is exactly 2s. With the real clock the test would take two seconds and would write a slightly different value above two seconds every time. synctest.Wait() waits until the other goroutines in the bubble (here the server goroutine handling the request) are blocked, so the check does not run before the log line is written.
The //go:build go1.27 on the first line of the file is needed because the module's go line is 1.26.0. Go 1.26 does not compile this file at all and passes with "no tests to run":
$ GOTOOLCHAIN=go1.26.11 go test -v -run TestLoggingDuration ./internal/httpapi
testing: warning: no tests to run
PASS
ok bookstore/internal/httpapi 0.121s [no tests to run]
Since the test changes the global logger with log.SetOutput, it does not call t.Parallel(); when we move to slog in post 13 we will pass the logger to the handler as a parameter and this global change will go away.
Fuzzing: Finding the Negative Amount Bug
A table-driven test tries the cases you think of. Fuzzing looks for the ones you do not. It has been part of go test since Go 1.18. In post 11 Money went to the database as Decimal() text and came back through ParseMoney; every amount must survive that round trip unchanged. The fuzz test states exactly that property:
// FuzzMoneyRoundTrip checks that every amount survives Decimal and ParseMoney,
// which is what happens on every trip to the database and back.
func FuzzMoneyRoundTrip(f *testing.F) {
for _, seed := range []int64{0, 5, 34990, 100, math.MinInt64} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, n int64) {
m := Money(n)
back, err := ParseMoney(m.Decimal())
if err != nil || back != m {
t.Fatalf("Money(%d).Decimal() = %q, ParseMoney gives %d, %v", n, m.Decimal(), back, err)
}
})
}
The seeds given with f.Add run like ordinary tests in a normal go test. With -fuzz, Go mutates those seeds to generate new inputs:
$ go test ./internal/catalog -run '^$' -fuzz FuzzMoneyRoundTrip -fuzztime 20s -parallel 4
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 4 workers
fuzz: elapsed: 0s, execs: 5 (48/sec), new interesting: 0 (total: 4)
--- FAIL: FuzzMoneyRoundTrip (0.11s)
--- FAIL: FuzzMoneyRoundTrip (0.00s)
money_test.go:46: Money(-21).Decimal() = "0.-21", ParseMoney gives 0, "0.-21": invalid money amount
Failing input written to testdata/fuzz/FuzzMoneyRoundTrip/00c6facf29213d38
To re-run:
go test -run=FuzzMoneyRoundTrip/00c6facf29213d38
FAIL
It found a negative number before the first second was over. It wrote the failing input to a file under testdata/fuzz/ (its content is the lines go test fuzz v1 and int64(-21)); I keep that file in the repo, and a normal go test runs it every time as a regression test. The fix is to keep the sign separate:
// Decimal formats the amount without a currency: Money(34990) is "349.90"
// and Money(-150) is "-1.50".
func (m Money) Decimal() string {
sign, abs := "", uint64(m)
if m < 0 {
sign, abs = "-", -uint64(m) // also right for math.MinInt64
}
return fmt.Sprintf("%s%d.%02d", sign, abs/100, abs%100)
}
Separating the sign brought out one more boundary case: the absolute value of math.MinInt64 does not fit in an int64. So I computed the absolute value in uint64, left the sign to strconv.ParseInt in ParseMoney, and added math.MinInt64 to the seeds. After the fix, a 30-second run tried about 8.9 million inputs and found nothing. It is common to run fuzzing in CI not on every commit but in a separate, time-limited job; a normal go test only tries the seeds and the saved inputs. This is the closest Go counterpart of FsCheck from the Unit Test series' "Snapshot ve Property-based Testing" post.
Benchmarks: Does Generic Code Cost Anything?
When we wrote paging.Of[T] in post 06, I said we would measure the speed of generic code in post 12. A benchmark is a BenchmarkXxx(b *testing.B) function; Go 1.24's b.Loop() keeps the setup before the loop out of the measurement and stops the compiler from removing the call inside the loop. For comparison I wrote a non-generic version doing the same work in the test file:
func ofBooks(items []book, page, size int) bookPage {
total := len(items)
start := min((page-1)*size, total)
end := min(start+size, total)
return bookPage{Items: items[start:end:end], Page: page, PageSize: size,
TotalItems: total, TotalPages: (total + size - 1) / size}
}
func BenchmarkOfGeneric(b *testing.B) {
bs := makeBooks(10_000)
for b.Loop() { // Go 1.24: setup above is not timed, the loop body is not optimised away
p := Of(bs, 37, 20)
sink += len(p.Items)
}
}
$ go test -run '^$' -bench . -benchmem -cpu 4 -count 3 ./internal/paging
pkg: bookstore/internal/paging
BenchmarkOfGeneric-4 458796928 2.817 ns/op 0 B/op 0 allocs/op
BenchmarkOfGeneric-4 448979904 2.660 ns/op 0 B/op 0 allocs/op
BenchmarkOfGeneric-4 441203031 2.669 ns/op 0 B/op 0 allocs/op
BenchmarkOfBooks-4 439244882 2.889 ns/op 0 B/op 0 allocs/op
BenchmarkOfBooks-4 404147508 2.982 ns/op 0 B/op 0 allocs/op
BenchmarkOfBooks-4 381018705 3.043 ns/op 0 B/op 0 allocs/op
PASS
There is no measurable difference between the two versions; both allocate nothing. Go compiles generics by the type parameter's "GC shape" (whether it is a pointer, what size it is) and generates dedicated code for a struct like book. A difference can show up in generic code that calls methods through the type parameter and is used with pointer types; there too, measure like this instead of guessing. To compare measurements, running several times with -count and using the benchstat tool is more reliable than looking at a single run.
Integration Tests, Coverage, vet and CI
The time zone bug from post 11 would not have been caught by a unit test with fakes; the bug lies between the driver and SQL Server. For that I added a test to sqlstore that goes to a real database. Without the environment variable the test skips itself, so go test ./... also passes on a machine without SQL Server:
// openTestDB connects to the database in BOOKSTORE_TEST_DB and skips the
// test when it is not set, so "go test ./..." also works without SQL Server.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("BOOKSTORE_TEST_DB")
if dsn == "" {
t.Skip("BOOKSTORE_TEST_DB is not set")
}
db, err := sql.Open("sqlserver", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err := sqlstore.EnsureSchema(t.Context(), db, catalog.Store{}.All()); err != nil {
t.Fatal(err)
}
return db
}
To see that the test does its job, I temporarily removed the .UTC() in Save:
--- FAIL: TestSaveAndLoad (0.15s)
sqlstore_test.go:55: CreatedAt = 2026-11-15 05:22:24.737 +0000 UTC, want 2026-11-15 05:22:24.737359 +0300 +03 m=+0.127131584 (off by 2h59m59.999641s)
FAIL
All the checks together, with the race detector and with coverage:
$ go vet ./... && go tool staticcheck ./...
$ BOOKSTORE_TEST_DB='sqlserver://...' go test -race ./...
ok bookstore/internal/catalog 1.223s
ok bookstore/internal/httpapi 1.261s
ok bookstore/internal/orders 1.219s
ok bookstore/internal/orders/sqlstore 1.490s
ok bookstore/internal/paging 1.218s
ok bookstore/labs/08-payment 1.214s
$ go test -cover ./internal/...
ok bookstore/internal/catalog 0.201s coverage: 34.6% of statements
ok bookstore/internal/httpapi 0.224s coverage: 36.8% of statements
ok bookstore/internal/orders 0.198s coverage: 71.9% of statements
bookstore/internal/orders/memstore coverage: 0.0% of statements
ok bookstore/internal/orders/sqlstore 0.481s coverage: 73.2% of statements
ok bookstore/internal/paging 0.207s coverage: 100.0% of statements
vet and staticcheck print nothing, so they are clean. The 0% for memstore is misleading: the handler tests use it, but by default coverage counts only the test's own package. -coverpkg=./internal/... measures all packages together; -coverprofile=cover.out and go tool cover -html=cover.out show it line by line. Use coverage to find an untested path, not as a target. go tool cover -func shows why catalog is at 34.6%: the catalog's query functions and Money's Scan/Value methods never run in this package's tests; Scan is exercised only by the sqlstore integration test.
The lines I run in CI are the same as above: go vet ./..., go tool staticcheck ./... and go test -race ./.... The race detector needs cgo; on Linux CI runners it works without installing anything extra, on Windows it needs a C compiler (for example mingw-w64). The pipeline file itself belongs to the CI/CD series; I did not run a CI file for this post.
Production Notes
go test caches results: if the code and inputs have not changed, the second run prints (cached). The test cache also takes the environment variables and files you read into account, so changing BOOKSTORE_TEST_DB reruns the test; but Go cannot know when the data in the database changes. Running integration tests in CI with -count=1 turns the cache off.
Integration tests can affect each other in a shared database. TestSaveAndLoad sets Emma's stock to 2 itself at the start, so it does not rely on data left by earlier runs. In a bigger test suite every test should create its own data, or every run should open its own temporary database.
Benchmark results depend on the machine, the load and the CPU frequency. Compare the numbers with the before and after on the same machine, not with numbers from another machine.
Common Pitfalls
Not using t.Run in a table-driven test. t.Fatal on the first failing row stops the whole table, and the broken row does not show up by name.
Calling t.Fatal from a goroutine. FailNow only works in the test's own goroutine; called from another goroutine, the test behaves unexpectedly. go vet's testinggoroutine check catches this; use t.Error in the goroutine, or send the error back over a channel.
Testing a handler without the mux. Path wildcards are filled in only by mux matching; a handler called directly sees an empty PathValue.
Tests that depend on the real clock. Waiting with time.Sleep produces tests that are either slow or flaky; inject the clock or use synctest.
Deleting the file the fuzzer found. The input under testdata/fuzz is the only thing proving the bug has not come back.
A Small Experiment
★☆☆ Add two rows to TestPlaceStockRule for a quantity of 0 and a negative quantity. Which error do you expect, and do you need to check it with errors.Is or with ValidationErrors?
★★☆ Write a table-driven handler test for GET /api/orders/{id}: first create an order with POST, then request an existing and a missing ID. Is the Content-Type of the 404 response application/problem+json?
★★★ Write a fuzz test for positiveInt: for every input where it returns true, check that the result is 1 or more and that strconv.Itoa(n) equals the input. Does the fuzzer find inputs like "+5" or "007"? Are they bugs for the API?
Tests, coverage and the race detector are in place; when we change the code we now see at once what broke. In the next post we take the service to production: structured logging with log/slog and the request ID in the log, moving the listen address and the CORS origin out of the code into configuration, graceful shutdown, health check endpoints, net/http/pprof on a separate port together with the goroutineleak profile from post 08, GOMAXPROCS, GOMEMLIMIT and the Green Tea GC, and a small container image.