Testing Patterns - HeyItWorked/babel-shelf GitHub Wiki
Same 8 integration tests, three different testing cultures. All hit a real Postgres database — no mocks.
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.
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:
-
TestMainconnects 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
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:
-
beforeAllconnects,afterAlldisconnects - Uses Hono's
app.request()— no real HTTP server - Dynamic
bookId— works with shared DB (IDs aren't predictable) -
describe/testblocks for grouping -
expect().toBe()assertions (Jest-style)
@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"The hardest part of integration testing is lifecycle management — connecting to the DB before tests and cleaning up after.
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.
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.
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
bookIddynamically - Go hardcodes
/books/1— works in isolation but fragile in shared environments
Every implementation has a sendRequest and sendAndExpect helper:
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
}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" }
})
}| 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.
// 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 |
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.
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.