Chapter 18: Testing
1. Why Tests Matter More Than You Think
Friday afternoon. Your client reports a critical bug in production. You fix it -- one line of code. But did that fix break something else? 47 routes. 12 ORM models. 3 middleware functions. Clicking through every page: an hour. Running the test suite: 2 seconds.
tina4ruby test Product API
✓ creates a product
✓ loads a product by id
✓ updates a product
✓ deletes a product
✓ filters products by category
Authentication
✓ logs in with valid credentials
✓ rejects invalid password
✓ protected route requires token
✓ allows access with valid token
9 tests: 9 passed, 0 failed, 0 errorsEverything still passes. Deploy with confidence. Weekend intact.
Tina4 ships an inline testing framework -- no RSpec, no Minitest, no setup ceremony. The Tina4::Testing module gives you describe/it blocks, a full set of assert_* helpers, and an in-process HTTP test client. tina4ruby test discovers *_test.rb and test_*.rb files under tests/, test/, spec/, or src/tests/ and runs them.
2. Your First Test
Tests live under tests/. Files must be named *_test.rb or test_*.rb -- the runner ignores anything else.
Create tests/basic_test.rb:
require "tina4"
Tina4::Testing.describe "Basic tests" do
it "adds two numbers" do
assert_equal(4, 2 + 2)
end
it "concatenates strings" do
result = "Hello" + " " + "World"
assert_equal("Hello World", result)
end
it "handles nil correctly" do
value = nil
assert_nil(value)
end
endRun it:
tina4ruby test Basic tests
✓ adds two numbers
✓ concatenates strings
✓ handles nil correctly
3 tests: 3 passed, 0 failed, 0 errorsHow It Works
Tina4::Testing.describeopens a suite. The block defines the suite's tests.- Each
itregisters a single test case. The description string becomes the label printed to the console. - Inside an
itblock you callassert_*helpers to check behaviour. Any assertion that fails raisesTina4::Testing::TestFailureand marks the test as failed. Tina4::Testing.run_allruns every registered suite.tina4ruby testcalls it for you.
3. Assertion Reference
Every assertion is defined on Tina4::Testing::TestContext and available inside it blocks. The convention is assert_<thing>(expected, actual, message = nil) -- expected first.
Equality
assert_equal(42, result) # value equality
assert_not_equal(0, result) # not equalNil
assert_nil(value) # value is nil
assert_not_nil(value) # value is not nilTruthiness
assert(condition, "optional message") # condition is truthy
assert_true(value) # value is truthy
assert_false(value) # value is falsyCollections and Strings
assert_includes([1, 2, 3], 2) # collection includes item
assert_match(/@/, "user@example.com") # string matches patternExceptions
assert_raises(ArgumentError) do
raise ArgumentError, "bad input"
endJSON / HTTP
data = assert_json(response.body) # parses body, raises on invalid JSON
assert_status(response, 200) # asserts HTTP status (Rack tuple OR TestResponse)4. Testing Routes
Tina4's Tina4::TestClient issues requests against your registered routes in-process -- no socket, no server, no port. It returns a Tina4::TestResponse with status, body, headers, and a json helper.
Create tests/product_test.rb:
require "tina4"
Tina4::Testing.describe "Product API" do
client = Tina4::TestClient.new
before_each do
db = Tina4.database
db.execute("DELETE FROM products") if db
end
it "returns the products list" do
resp = client.get("/api/products")
assert_status(resp, 200)
data = resp.json
assert_not_nil(data, "Response should be valid JSON")
assert_includes(data.keys, "products")
assert(data["products"].is_a?(Array), "products should be an array")
end
it "creates a product" do
resp = client.post("/api/products", json: {
name: "Test Widget",
category: "Testing",
price: 9.99
})
assert_status(resp, 201)
data = resp.json
assert_equal("Test Widget", data["name"])
assert_equal(9.99, data["price"])
assert_not_nil(data["id"], "New product should have an id")
end
it "returns 404 for a missing product" do
resp = client.get("/api/products/99999")
assert_status(resp, 404)
end
it "validates required fields" do
resp = client.post("/api/products", json: {})
assert_status(resp, 400)
data = resp.json
assert_match(/required/, data["error"])
end
endTestClient Methods
Tina4::TestClient exposes one method per verb. Every method except get/delete accepts a json: keyword for the request body. Use headers: to attach a single hash of HTTP headers.
client = Tina4::TestClient.new
# GET — with optional query string and headers
resp = client.get("/api/products")
resp = client.get("/api/products?category=Electronics")
resp = client.get("/api/profile", headers: { "Authorization" => "Bearer #{token}" })
# POST / PUT / PATCH — pass a Ruby hash via `json:`
resp = client.post("/api/products", json: { name: "Widget", price: 9.99 })
resp = client.put("/api/products/1", json: { name: "Updated Widget" })
resp = client.patch("/api/products/1", json: { price: 12.99 })
# DELETE — no body
resp = client.delete("/api/products/1")TestResponse
resp.status # HTTP status code (200, 201, 404, ...)
resp.body # raw response body as a String
resp.headers # response headers as a Hash (lowercased keys)
resp.content_type # value of the content-type header
resp.json # JSON.parse(body); returns nil if the body is not valid JSON
resp.text # alias for body.to_sIf you prefer to assert on the raw Rack tuple instead of a TestResponse, use the get/post/put/delete helpers exposed on the test context -- they return [status, headers, body_enumerable] and work with assert_status exactly the same way.
5. Testing ORM Models
Drop into the database directly when you need to verify model behaviour. before_each is the right place to truncate tables or seed fixtures.
require "tina4"
Tina4::Testing.describe "Product model" do
before_each do
db = Tina4.database
db.execute("DELETE FROM products")
end
it "saves and reloads a product" do
product = Product.new
product.name = "Test Widget"
product.category = "Testing"
product.price = 19.99
product.save
assert_not_nil(product.id, "Product should have an id after save")
loaded = Product.find(product.id)
assert_equal("Test Widget", loaded.name)
assert_equal(19.99, loaded.price)
end
it "updates a product" do
product = Product.create(name: "Before", price: 10.0, category: "Testing")
product.name = "After"
product.price = 20.0
product.save
reloaded = Product.find(product.id)
assert_equal("After", reloaded.name)
assert_equal(20.0, reloaded.price)
end
it "deletes a product" do
product = Product.create(name: "Goodbye", price: 5.0, category: "Testing")
id = product.id
product.delete
assert_nil(Product.find(id), "Deleted product should not be findable")
end
it "filters by category" do
Product.create(name: "Phone", category: "Electronics", price: 499.0)
Product.create(name: "Yoga Mat", category: "Fitness", price: 29.0)
results = Product.where("category = ?", ["Electronics"])
assert_equal(1, results.length)
assert_equal("Phone", results[0].name)
end
endTest database isolation
By default, the framework uses whatever TINA4_DATABASE_URL points at. To keep tests off your development data, point them at a dedicated SQLite file:
# .env.test
TINA4_DATABASE_URL=sqlite:///data/test.db
TINA4_DEBUG=falseLoad it explicitly in your test bootstrap, or set TINA4_ENV=test and have your app pick the matching .env.test at startup.
6. Testing Authentication
Auth flows are easy to test because TestClient works in-process -- there is no JWT round-trip over the wire.
require "tina4"
Tina4::Testing.describe "Authentication" do
client = Tina4::TestClient.new
before_each do
User.where("email = ?", ["test@example.com"]).each(&:delete)
client.post("/api/auth/register", json: {
name: "Test User",
email: "test@example.com",
password: "SecurePass123!"
})
end
it "logs in with valid credentials" do
resp = client.post("/api/auth/login", json: {
email: "test@example.com",
password: "SecurePass123!"
})
assert_status(resp, 200)
body = resp.json
assert_not_nil(body["token"], "Should return a JWT token")
assert(body["token"].length > 50, "Token should be substantial")
end
it "rejects an invalid password" do
resp = client.post("/api/auth/login", json: {
email: "test@example.com",
password: "wrong-password"
})
assert_status(resp, 401)
assert_match(/invalid/i, resp.json["error"])
end
it "protects routes without a token" do
resp = client.get("/api/profile")
assert_status(resp, 401)
end
it "allows access with a valid token" do
login = client.post("/api/auth/login", json: {
email: "test@example.com",
password: "SecurePass123!"
})
token = login.json["token"]
resp = client.get("/api/profile", headers: {
"Authorization" => "Bearer #{token}"
})
assert_status(resp, 200)
assert_equal("test@example.com", resp.json["email"])
end
end7. Setup and Teardown
before_each runs before every test in the suite. after_each runs after every test, even on failure. Use them to create fixtures and clean up state:
Tina4::Testing.describe "User Management" do
client = Tina4::TestClient.new
user_id = nil
before_each do
user = User.new(name: "Test User", email: "fixture@example.com")
user.save
user_id = user.id
end
after_each do
User.find(user_id)&.delete if user_id
end
it "loads the user" do
loaded = User.find(user_id)
assert_equal("Test User", loaded.name)
end
it "updates the user" do
user = User.find(user_id)
user.name = "Updated Name"
user.save
reloaded = User.find(user_id)
assert_equal("Updated Name", reloaded.name)
end
endThere is no per-suite hook. If you need one-off setup that applies to every test, do it at the top of the file before describe, or accept the cost of running it in before_each.
8. Running Tests
# Run every discovered test file
tina4ruby testThe runner walks tests/, test/, spec/, and src/tests/ (in that order) and loads every *_test.rb and test_*.rb file it finds. Inline tests declared inside route files are picked up automatically because tina4ruby test also loads routes/.
Output uses ANSI colours: green checks for passes, red crosses for failures, and yellow bangs for unexpected exceptions. Exit code is non-zero if any test fails or errors -- perfect for CI.
Product API
✓ returns the products list
✓ creates a product
✗ returns 404 for a missing product: Expected status 404, got 200
✓ validates required fields
4 tests: 3 passed, 1 failed, 0 errorsThe failure line shows the suite name, the test description, and the assertion message. Find the line. Fix the logic. Run again.
Embedding the runner in your own scripts
require "tina4"
# Load whichever test files you want, then:
results = Tina4::Testing.run_all(quiet: false, failfast: false)
exit(results[:failed] > 0 || results[:errors] > 0 ? 1 : 0)run_all returns a hash with :passed, :failed, :errors, and a :tests array of { name:, status:, suite:, message: } entries. Pass quiet: true to suppress console output and inspect the hash directly. Pass failfast: true to stop at the first failure.
9. Testing Best Practices
Test one thing per it
Each test should verify one behaviour. When it fails, you know exactly what broke.
# Good — each test verifies one thing
it "returns 201 on create" do
resp = client.post("/api/products", json: { name: "Widget", price: 9.99 })
assert_status(resp, 201)
end
it "returns the created product" do
resp = client.post("/api/products", json: { name: "Widget", price: 9.99 })
assert_equal("Widget", resp.json["name"])
end
# Bad — testing five things in one block
it "does everything" do
# Creates, reads, updates, deletes, checks auth, validates input ...
# When this fails you have no idea which step broke.
endUse descriptive test names
# Good
it "returns 404 when the product does not exist"
# Bad
it "works"Isolate tests
Each test should create its own data and clean up after itself. Never depend on rows left behind by another test.
# Good
it "deletes a product" do
product = Product.create(name: "Temporary", price: 1.0)
product.delete
assert_nil(Product.find(product.id))
endGenerate unique values
Avoid UNIQUE constraint failures by mixing in a random or time-based suffix:
require "securerandom"
it "registers a user" do
email = "test-#{SecureRandom.hex(4)}@example.com"
resp = client.post("/api/auth/register", json: {
name: "Test", email: email, password: "Pass1234!"
})
assert_status(resp, 201)
endCompare floats with tolerance
assert_equal(9.99, product.price) can fail when SQLite hands back 9.990000000000001. When the engine is fussy, use a manual tolerance:
diff = (product.price - 9.99).abs
assert(diff < 0.01, "Expected ~9.99, got #{product.price}")10. Exercise: Write Tests for a Notes API
Write a test suite for the Notes API from Chapter 5.
Requirements
Cover these scenarios:
- Create a note with valid data (201)
- Reject a note with a missing title (400)
- List all notes (200)
- Get a note by id (200)
- Return 404 for a non-existent note
- Update a note (200)
- Delete a note (204)
- Search notes by content
- Filter notes by tag
11. Solution
Create tests/notes_test.rb:
require "tina4"
Tina4::Testing.describe "Notes API" do
client = Tina4::TestClient.new
before_each do
db = Tina4.database
db.execute("DELETE FROM notes")
end
it "creates a note with valid data" do
resp = client.post("/api/notes", json: {
title: "Test Note",
content: "This is a test",
tag: "testing"
})
assert_status(resp, 201)
body = resp.json
assert_equal("Test Note", body["title"])
assert_equal("testing", body["tag"])
assert_not_nil(body["id"])
end
it "rejects a note with a missing title" do
resp = client.post("/api/notes", json: { content: "No title here" })
assert_status(resp, 400)
assert_match(/title/i, resp.json["error"])
end
it "lists all notes" do
client.post("/api/notes", json: { title: "Note 1", content: "Content 1" })
client.post("/api/notes", json: { title: "Note 2", content: "Content 2" })
resp = client.get("/api/notes")
assert_status(resp, 200)
assert_equal(2, resp.json["notes"].length)
end
it "gets a note by id" do
created = client.post("/api/notes", json: { title: "Find Me", content: "Here I am" })
id = created.json["id"]
resp = client.get("/api/notes/#{id}")
assert_status(resp, 200)
assert_equal("Find Me", resp.json["title"])
end
it "returns 404 for a non-existent note" do
resp = client.get("/api/notes/99999")
assert_status(resp, 404)
end
it "updates a note" do
created = client.post("/api/notes", json: { title: "Original", content: "Original content" })
id = created.json["id"]
resp = client.put("/api/notes/#{id}", json: { title: "Updated", content: "Updated content" })
assert_status(resp, 200)
assert_equal("Updated", resp.json["title"])
end
it "deletes a note" do
created = client.post("/api/notes", json: { title: "Delete Me", content: "Goodbye" })
id = created.json["id"]
resp = client.delete("/api/notes/#{id}")
assert_status(resp, 204)
assert_status(client.get("/api/notes/#{id}"), 404)
end
it "searches notes by content" do
client.post("/api/notes", json: { title: "Shopping", content: "Buy milk and eggs" })
client.post("/api/notes", json: { title: "Work", content: "Finish the report" })
resp = client.get("/api/notes?search=milk")
assert_status(resp, 200)
assert_equal(1, resp.json["notes"].length)
assert_equal("Shopping", resp.json["notes"][0]["title"])
end
it "filters notes by tag" do
client.post("/api/notes", json: { title: "Personal", content: "Content", tag: "personal" })
client.post("/api/notes", json: { title: "Work", content: "Content", tag: "work" })
resp = client.get("/api/notes?tag=personal")
assert_status(resp, 200)
assert_equal(1, resp.json["notes"].length)
assert_equal("Personal", resp.json["notes"][0]["title"])
end
end12. Gotchas
1. Files must be named correctly
Problem: Your tests don't run.
Cause: The runner only loads *_test.rb and test_*.rb. A file called notes_spec.rb will be skipped.
Fix: Rename to notes_test.rb or test_notes.rb.
2. Database state carries between tests
Problem: A test passes alone but fails when the whole suite runs.
Fix: Truncate tables in before_each or use unique values per test (UUID/timestamp suffix on emails, etc.).
3. TestClient doesn't start a real server
Problem: External services (SMTP, Redis, third-party APIs) are not available in tests.
Fix: TestClient matches routes and runs handlers in-process. Mock out anything that crosses the network boundary, or use the dev mailbox / fake queue backends shipped with the framework.
4. Argument order on equality assertions
Problem: The failure message reads "Expected: 200, got: 404" but you wanted to compare the other way around.
Fix: The Tina4 convention is assert_equal(expected, actual) -- expected first, like Minitest. Keep the order consistent across your suite.
5. Floating point comparisons
Problem: assert_equal(9.99, product.price) fails with 9.990000000000001.
Fix: Compare against a tolerance with assert(diff < 0.01, ...) as shown above, or store currency as integer cents.
6. Tests can't find your models
Problem: NameError: uninitialized constant Product when the test loads.
Cause: Routes and ORM models are normally loaded by the server boot path. tina4ruby test calls Tina4.initialize!(Dir.pwd) followed by route discovery, but a model file that isn't required anywhere won't load on its own.
Fix: Either require_relative "../src/orm/product" at the top of the test file, or move the model into the autoloaded src/orm/ directory.
7. Tests pass locally but fail in CI
Problem: All green on your machine, red in CI.
Cause: Different Ruby version, missing env vars, different SQLite version, time-dependent assertions.
Fix: Pin the Ruby version in CI (.ruby-version), commit .env.test if it contains no secrets, and avoid asserting on wall-clock time or random values.