Testing Patterns - HeyItWorked/babel-shelf GitHub Wiki

Testing Patterns

Same 8 integration tests, three different testing cultures. All hit a real Postgres database — no mocks.


The 8 Test Cases

Every implementation tests the exact same scenarios:

# Test Method Path Expected
1 Create book POST /books 201 + book with id
2 List books GET /books 200 + non-empty list
3 Get book GET /books/:id 200 + book with all fields
4 Get book not found GET /books/99999 404
5 Update book PUT /books/:id 200 + updated status
6 Delete book DELETE /books/:id 204
7 Create without title POST /books 400
8 Create invalid status POST /books 400

Test order matters: Create → Read → Update → Delete follows the CRUD lifecycle. Later tests depend on data created by earlier ones.


Test Architecture Comparison

Go — Flat, Minimal

go-bookshelf/
├── handlers_test.go          ← 8 test functions
└── test_helpers_test.go      ← TestMain + helpers
func TestCreateBook(t *testing.T) {
    rr := sendAndExpect(t, "POST", "/books", `{"title": "Dune", "author": "Frank Herbert"}`, http.StatusCreated)
    got := decodeBook(t, rr)
    if got.Title != "Dune" {
        t.Errorf("got title %q, want %q", got.Title, "Dune")
    }
}

Key characteristics:

  • TestMain connects to DB before any tests run
  • Uses httptest.NewRecorder — no real HTTP server
  • Hardcodes /books/1 (assumes fresh DB with sequential IDs)
  • Manual assertions: if got != want { t.Errorf(...) }
  • No test grouping — each test is a standalone function

TypeScript — Grouped, Dynamic IDs

ts-bookshelf/
└── test/
    ├── handlers.test.ts      ← 8 tests in a describe block
    └── helpers.ts            ← beforeAll/afterAll + helpers
let bookId: number  // captured from create, used in later tests

describe("books", () => {
    test("create book", async () => {
        const res = await sendAndExpect("POST", "/books", { title: "Dune", author: "Frank Herbert" }, 201)
        const body = await res.json()
        expect(body.title).toBe("Dune")
        bookId = body.id  // save for later
    })

    test("get book", async () => {
        const res = await sendAndExpect("GET", `/books/${bookId}`, undefined, 200)
        // uses dynamic bookId instead of hardcoded /books/1
    })
})

Key characteristics:

  • beforeAll connects, afterAll disconnects
  • Uses Hono's app.request() — no real HTTP server
  • Dynamic bookId — works with shared DB (IDs aren't predictable)
  • describe/test blocks for grouping
  • expect().toBe() assertions (Jest-style)

Python (hypothetical) — Fixtures, Context Managers

@pytest.fixture
def db():
    conn = psycopg2.connect(DATABASE_URL)
    yield conn
    conn.close()

def test_create_book(client, db):
    res = client.post("/books", json={"title": "Dune", "author": "Frank Herbert"})
    assert res.status_code == 201
    assert res.json()["title"] == "Dune"

Setup Patterns

The hardest part of integration testing is lifecycle management — connecting to the DB before tests and cleaning up after.

Go: TestMain

func TestMain(m *testing.M) {
    db, _ = sql.Open("postgres", databaseURL)
    defer db.Close()
    os.Exit(m.Run())
}

TestMain is special — Go's test runner calls it instead of running tests directly. You connect, call m.Run() to execute all tests, then exit with the result code.

Gotcha: TestMain must be in a _test.go file. If you put it in a regular file, Go ignores it.

TypeScript: beforeAll/afterAll

beforeAll(async () => {
    setPool(new Pool({ connectionString: databaseUrl }))
})

afterAll(async () => {
    await pool.end()
})

Bun's test runner calls beforeAll before the first test in the file and afterAll after the last. The setPool pattern is needed because ES modules can't reassign imports.

The Shared DB Problem

Both implementations share the Postgres database across Go and TS tests. This means:

  • IDs are not predictable (Go might have created rows before TS tests run)
  • TS solved this by capturing bookId dynamically
  • Go hardcodes /books/1 — works in isolation but fragile in shared environments

Request Helpers — Same Shape, Different Syntax

Every implementation has a sendRequest and sendAndExpect helper:

Go

func sendRequest(method, url, body string) *httptest.ResponseRecorder {
    var req *http.Request
    if body != "" {
        req, _ = http.NewRequest(method, url, strings.NewReader(body))
    } else {
        req, _ = http.NewRequest(method, url, nil)
    }
    req.Header.Set("Content-Type", "application/json")
    rr := httptest.NewRecorder()
    handler := setupRouter()
    handler.ServeHTTP(rr, req)
    return rr
}

TypeScript

export async function sendRequest(method: string, url: string, body?: object): Promise<Response> {
    return await app.request(url, {
        method,
        body: body ? JSON.stringify(body) : undefined,
        headers: { "Content-Type": "application/json" }
    })
}

Comparison

Aspect Go TypeScript
Body format Raw JSON string Object (auto-serialized)
Response type *httptest.ResponseRecorder Response (Web API)
Read response body decodeBook(t, rr) await res.json()
Server involvement None — handler.ServeHTTP() None — app.request()
Async No Yes (await)

Neither uses real HTTP. Go's httptest.NewRecorder() and Hono's app.request() both invoke the handler stack in-memory. This makes tests fast and avoids port conflicts.


Assertion Styles

// Go — manual comparison, write your own message
if got.Title != "Dune" {
    t.Errorf("got title %q, want %q", got.Title, "Dune")
}
// TypeScript — expect/toBe (Jest API)
expect(body.title).toBe("Dune")
# Python — assert statement
assert body["title"] == "Dune"
Style Produces helpful failure message? Custom matchers?
Go t.Errorf Only if you write one No — roll your own
Bun expect Yes — shows got vs want Yes — .toContain(), .toBeGreaterThan(), etc.
Python assert Partial — pytest rewrites for better output Via pytest plugins

What Changes for Gleam

Gleam will use gleeunit (or startest) with should assertions:

pub fn create_book_test() {
  let response = send_and_expect(Post, "/books", some_body, 201)
  let book = decode_response(response)
  book.title |> should.equal("Dune")
  book.status |> should.equal(WantToRead)
}

The setup will pass ctx (with db connection) explicitly — no global state, no beforeAll mutation.


Why Integration Tests (Not Unit Tests)?

All three implementations test the full stack: HTTP request → handler → DB query → Postgres → response. No mocks.

Tradeoffs:

Aspect Integration (what we do) Unit (what we don't)
Requires Postgres running Yes No
Catches real SQL bugs Yes No
Tests handler + DB together Yes Separately
Speed Slower (~100ms per test) Fast (~1ms per test)
Confidence High Medium

For a CRUD app with no business logic between the handler and DB layers, integration tests give the most value. Unit-testing insertBook in isolation would just test that pool.query was called — not that the SQL actually works.

⚠️ **GitHub.com Fallback** ⚠️