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) endendRun 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 equalityassert_not_equal(0, result) # not equalNil#
assert_nil(value) # value is nilassert_not_nil(value) # value is not nilTruthiness#
assert(condition, "optional message") # condition is truthyassert_true(value) # value is truthyassert_false(value) # value is falsyCollections and Strings#
assert_includes([1, 2, 3], 2) # collection includes itemassert_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 JSONassert_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"]) endendTestClient 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 headersresp = 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 bodyresp = client.delete("/api/products/1")TestResponse#
resp.status # HTTP status code (200, 201, 404, ...)resp.body # raw response body as a Stringresp.headers # response headers as a Hash (lowercased keys)resp.content_type # value of the content-type headerresp.json # JSON.parse(body); returns nil if the body is not valid JSONresp.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) endendTest 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.testTINA4_DATABASE_URL=sqlite:///data/test.dbTINA4_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"]) endend7. 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) endendThere 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 filetina4ruby 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 thingit "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 blockit "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#
# Goodit "returns 404 when the product does not exist"โ# Badit "works"Isolate tests#
Each test should create its own data and clean up after itself. Never depend on rows left behind by another test.
# Goodit "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).absassert(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"]) endend12. 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.