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 anything else? You have 47 routes, 12 ORM models, and 3 middleware functions. Manually clicking through every page takes an hour. Running the test suite takes 2 seconds.
tina4 testRunning tests...โ ProductTest [PASS] test_create_product [PASS] test_load_product [PASS] test_update_product [PASS] test_delete_product [PASS] test_list_products_with_filterโ AuthTest [PASS] test_login_with_valid_credentials [PASS] test_login_with_invalid_password [PASS] test_protected_route_without_token [PASS] test_protected_route_with_valid_tokenโ 9 tests, 9 passed, 0 failed (0.34s)Everything still works. Deploy with confidence. Enjoy your weekend.
Tina4 includes an inline testing framework. No external packages. No PHPUnit configuration. Write a test. Run it. Done.
2. Your First Test#
Tests live in the tests/ directory. Every .php file there is auto-discovered when you run tina4 test.
Create tests/BasicTest.php:
<?phpuse Tina4\Test;โclass BasicTest extends Test{ public function testAddition() { $this->assertEqual(2 + 2, 4, "Basic addition should work"); }โ public function testStringContains() { $greeting = "Hello, World!"; $this->assertTrue(str_contains($greeting, "World"), "Greeting should contain 'World'"); }โ public function testArrayLength() { $items = [1, 2, 3, 4, 5]; $this->assertEqual(count($items), 5, "Array should have 5 items"); }}Run it:
tina4 testRunning tests...โ BasicTest [PASS] test_addition [PASS] test_string_contains [PASS] test_array_lengthโ 3 tests, 3 passed, 0 failed (0.02s)How It Works#
- Your test class extends
Tina4\Test. - Every method that starts with
testis a test case. The method name is converted to a readable label:testAdditionbecomestest_addition. - Inside each test, you call assertion methods to verify behavior.
- If all assertions pass, the test passes. If any assertion fails, the test fails and you see the failure message.
3. Assertion Methods#
Tina4's Test class provides these assertion methods:
assertEqual($actual, $expected, $message)#
Checks that two values are equal.
$this->assertEqual(4, 4, "Should be equal"); // PASS$this->assertEqual("hello", "hello", "Strings match"); // PASS$this->assertEqual(4, 5, "Not equal"); // FAILassertTrue($value, $message)#
Checks that a value is truthy.
$this->assertTrue(true, "Should be true"); // PASS$this->assertTrue(1, "1 is truthy"); // PASS$this->assertTrue("yes", "Non-empty string is truthy"); // PASS$this->assertTrue(false, "This fails"); // FAIL$this->assertTrue(0, "Zero is falsy"); // FAILassertFalse($value, $message)#
Checks that a value is falsy.
$this->assertFalse(false, "Should be false"); // PASS$this->assertFalse(0, "Zero is falsy"); // PASS$this->assertFalse("", "Empty string is falsy"); // PASS$this->assertFalse(true, "This fails"); // FAILassertRaises($callable, $exceptionClass, $message)#
Checks that a function throws a specific exception.
$this->assertRaises(function () { throw new \InvalidArgumentException("Bad input");}, \InvalidArgumentException::class, "Should throw InvalidArgumentException");โ$this->assertRaises(function () { $result = 10 / 0;}, \DivisionByZeroError::class, "Should throw on division by zero");assertNotEqual($actual, $expected, $message)#
Checks that two values are not equal.
$this->assertNotEqual("hello", "world", "Strings differ"); // PASS$this->assertNotEqual(4, 4, "Same values"); // FAILassertNull($value, $message)#
Checks that a value is null.
$this->assertNull(null, "Should be null"); // PASS$this->assertNull("hello", "Not null"); // FAILassertNotNull($value, $message)#
Checks that a value is not null.
$this->assertNotNull("hello", "Has value"); // PASS$this->assertNotNull(null, "Is null"); // FAIL4. Testing ORM Models#
Test a Product model. Create records. Load them. Update them. Delete them.
Create tests/ProductTest.php:
<?phpuse Tina4\Test;โclass ProductTest extends Test{ private ?int $testProductId = null;โ public function testCreateProduct() { $product = new Product(); $product->name = "Test Widget"; $product->category = "Testing"; $product->price = 19.99; $product->inStock = true; $product->save();โ $this->assertNotNull($product->id, "Product should have an ID after save"); $this->assertTrue($product->id > 0, "Product ID should be positive");โ $this->testProductId = $product->id; }โ public function testLoadProduct() { // Create a product to load $product = new Product(); $product->name = "Load Test Widget"; $product->category = "Testing"; $product->price = 29.99; $product->save();โ // Load it back $loaded = new Product(); $loaded->load($product->id);โ $this->assertEqual($loaded->name, "Load Test Widget", "Name should match"); $this->assertEqual($loaded->category, "Testing", "Category should match"); $this->assertEqual($loaded->price, 29.99, "Price should match"); $this->assertTrue($loaded->inStock, "Should be in stock by default"); }โ public function testUpdateProduct() { $product = new Product(); $product->name = "Update Test Widget"; $product->price = 10.00; $product->save();โ $id = $product->id;โ // Update it $product->name = "Updated Widget"; $product->price = 15.00; $product->save();โ // Reload and verify $reloaded = new Product(); $reloaded->load($id);โ $this->assertEqual($reloaded->name, "Updated Widget", "Name should be updated"); $this->assertEqual($reloaded->price, 15.00, "Price should be updated"); }โ public function testDeleteProduct() { $product = new Product(); $product->name = "Delete Me"; $product->price = 5.00; $product->save();โ $id = $product->id; $product->delete();โ // Try to load the deleted product $gone = new Product(); $gone->load($id);โ $this->assertTrue(empty($gone->id), "Deleted product should not be loadable"); }โ public function testSelectWithFilter() { // Create products in different categories $p1 = new Product(); $p1->name = "Filter Test A"; $p1->category = "FilterCat"; $p1->price = 10.00; $p1->save();โ $p2 = new Product(); $p2->name = "Filter Test B"; $p2->category = "FilterCat"; $p2->price = 20.00; $p2->save();โ $p3 = new Product(); $p3->name = "Other Product"; $p3->category = "Other"; $p3->price = 30.00; $p3->save();โ // Query by category $product = new Product(); $results = $product->select("*", "category = :cat", ["cat" => "FilterCat"]);โ $this->assertTrue(count($results) >= 2, "Should find at least 2 FilterCat products");โ $names = array_map(fn($p) => $p->name, $results); $this->assertTrue(in_array("Filter Test A", $names), "Should include Filter Test A"); $this->assertTrue(in_array("Filter Test B", $names), "Should include Filter Test B"); }}Run it:
tina4 testRunning tests...โ ProductTest [PASS] test_create_product [PASS] test_load_product [PASS] test_update_product [PASS] test_delete_product [PASS] test_select_with_filterโ 5 tests, 5 passed, 0 failed (0.18s)Test Database#
By default, tina4 test uses a separate test database so your development data is not affected. The test database is created at data/test.db (SQLite) and is reset before each test run. If you want to use a different database for tests, set it in .env:
TINA4_DATABASE_URL=sqlite:///data/test.db5. Testing Routes#
Tina4 provides a test client for HTTP requests to your routes. No server needed.
Create tests/RouteTest.php:
<?phpuse Tina4\Test;โclass RouteTest extends Test{ public function testHealthEndpoint() { $response = $this->get("/health");โ $this->assertEqual($response->status, 200, "Health check should return 200");โ $body = json_decode($response->body, true); $this->assertEqual($body["status"], "ok", "Status should be 'ok'"); $this->assertNotNull($body["version"], "Should include version"); }โ public function testGetProducts() { $response = $this->get("/api/products");โ $this->assertEqual($response->status, 200, "Should return 200");โ $body = json_decode($response->body, true); $this->assertTrue(isset($body["data"]) || isset($body["products"]), "Should contain product data"); }โ public function testCreateProduct() { $response = $this->post("/api/products", [ "name" => "Route Test Product", "category" => "Testing", "price" => 42.00 ]);โ $this->assertEqual($response->status, 201, "Should return 201 Created");โ $body = json_decode($response->body, true); $this->assertEqual($body["name"], "Route Test Product", "Name should match"); $this->assertEqual($body["price"], 42.00, "Price should match"); }โ public function testGetProductNotFound() { $response = $this->get("/api/products/99999");โ $this->assertEqual($response->status, 404, "Should return 404 for missing product"); }โ public function testCreateProductValidation() { $response = $this->post("/api/products", []);โ $this->assertEqual($response->status, 400, "Should return 400 for empty body"); }โ public function testDeleteProduct() { // Create a product first $createResponse = $this->post("/api/products", [ "name" => "To Be Deleted", "price" => 1.00 ]); $body = json_decode($createResponse->body, true); $id = $body["id"];โ // Delete it $deleteResponse = $this->delete("/api/products/" . $id); $this->assertEqual($deleteResponse->statusCode, 204, "Should return 204 No Content");โ // Verify it is gone $getResponse = $this->get("/api/products/" . $id); $this->assertEqual($getResponse->statusCode, 404, "Should return 404 after deletion"); }}Test Client Methods#
The test client provides methods for all HTTP verbs:
// GET request$response = $this->get("/api/products");โ// GET with query parameters$response = $this->get("/api/products?category=Electronics&page=2");โ// POST with JSON body$response = $this->post("/api/products", ["name" => "Widget", "price" => 9.99]);โ// PUT with JSON body$response = $this->put("/api/products/1", ["name" => "Updated Widget"]);โ// PATCH with JSON body$response = $this->patch("/api/products/1", ["price" => 12.99]);โ// DELETE$response = $this->delete("/api/products/1");โ// Request with custom headers$response = $this->get("/api/profile", [ "Authorization" => "Bearer eyJhbGciOiJIUzI1NiIs..."]);Response Object#
The response object has these properties:
$response->status; // HTTP status code (200, 201, 404, etc.)$response->body; // Response body as a string$response->headers; // Response headers as an associative array (lowercase keys)$response->contentType; // Content-Type header value$response->json(); // Parse body as associative array (or null if not JSON)$response->text(); // Body as a string (alias for ->body)6. Testing Authentication#
Create tests/AuthTest.php:
<?phpuse Tina4\Test;โclass AuthTest extends Test{ private ?string $token = null;โ public function testLoginWithValidCredentials() { $response = $this->post("/api/auth/login", [ "email" => "admin@example.com", "password" => "correct-password" ]);โ $this->assertEqual($response->status, 200, "Login should succeed");โ $body = json_decode($response->body, true); $this->assertNotNull($body["token"], "Should return a JWT token"); $this->assertTrue(strlen($body["token"]) > 50, "Token should be a substantial string");โ $this->token = $body["token"]; }โ public function testLoginWithInvalidPassword() { $response = $this->post("/api/auth/login", [ "email" => "admin@example.com", "password" => "wrong-password" ]);โ $this->assertEqual($response->status, 401, "Should reject invalid password"); }โ public function testLoginWithMissingFields() { $response = $this->post("/api/auth/login", [ "email" => "admin@example.com" ]);โ $this->assertTrue( $response->status === 400 || $response->status === 401, "Should reject missing password" ); }โ public function testProtectedRouteWithoutToken() { $response = $this->get("/api/profile");โ $this->assertEqual($response->status, 401, "Should reject unauthenticated request"); }โ public function testProtectedRouteWithValidToken() { // Login first to get a token $loginResponse = $this->post("/api/auth/login", [ "email" => "admin@example.com", "password" => "correct-password" ]); $loginBody = json_decode($loginResponse->body, true); $token = $loginBody["token"];โ // Access protected route with token $response = $this->get("/api/profile", [ "Authorization" => "Bearer " . $token ]);โ $this->assertEqual($response->status, 200, "Should allow authenticated request");โ $body = json_decode($response->body, true); $this->assertEqual($body["user"]["email"], "admin@example.com", "Should return user data"); }โ public function testProtectedRouteWithInvalidToken() { $response = $this->get("/api/profile", [ "Authorization" => "Bearer invalid.token.here" ]);โ $this->assertEqual($response->status, 401, "Should reject invalid token"); }}7. Setup and Teardown#
Use setUp() and tearDown() methods to run code before and after each test:
<?phpuse Tina4\Test;โclass UserTest extends Test{ private ?int $userId = null;โ public function setUp(): void { // Runs before each test $user = new User(); $user->name = "Test User"; $user->email = "test-" . time() . "@example.com"; $user->save(); $this->userId = $user->id; }โ public function tearDown(): void { // Runs after each test if ($this->userId) { $user = new User(); $user->load($this->userId); if (!empty($user->id)) { $user->delete(); } } }โ public function testUserExists() { $user = new User(); $user->load($this->userId); $this->assertNotNull($user->id, "User should exist"); $this->assertEqual($user->name, "Test User", "Name should match"); }โ public function testUpdateUser() { $user = new User(); $user->load($this->userId); $user->name = "Updated Name"; $user->save();โ $reloaded = new User(); $reloaded->load($this->userId); $this->assertEqual($reloaded->name, "Updated Name", "Name should be updated"); }}setUp() runs before every test method. tearDown() runs after every test method. Pass or fail, the cleanup runs. Each test starts with a clean state.
8. Running Tests#
Run All Tests#
tina4 testtina4 test runs the full PHPUnit suite under tests/. For more targeted runs, invoke PHPUnit directly, the framework's tina4 test is a convenience wrapper that shells out to vendor/bin/phpunit tests --verbose --color.
Run a Specific Test File#
vendor/bin/phpunit tests/ProductTest.phpRun a Specific Test Method#
vendor/bin/phpunit --filter testCreateProduct tests/ProductTest.phpVerbose Output#
vendor/bin/phpunit tests --verbose --colorRunning tests...โ ProductTest [PASS] test_create_product (0.03s) assertEqual: Product should have an ID after save assertTrue: Product ID should be positive [PASS] test_load_product (0.02s) assertEqual: Name should match assertEqual: Category should match assertEqual: Price should match assertTrue: Should be in stock by default [PASS] test_update_product (0.04s) assertEqual: Name should be updated assertEqual: Price should be updated [PASS] test_delete_product (0.02s) assertTrue: Deleted product should not be loadable [PASS] test_select_with_filter (0.05s) assertTrue: Should find at least 2 FilterCat products assertTrue: Should include Filter Test A assertTrue: Should include Filter Test Bโ 5 tests, 5 passed, 0 failed (0.16s)Verbose mode shows each assertion within each test, along with timing information.
Failed Test Output#
When a test fails, you see exactly what went wrong:
ProductTest [PASS] test_create_product [FAIL] test_load_product assertEqual FAILED: Name should match Expected: "Load Test Widget" Actual: "Wrong Name" File: tests/ProductTest.php:34 [PASS] test_update_productโ 3 tests, 2 passed, 1 failed (0.12s)The failure message shows the assertion that failed, what was expected, what was actually received, and the file and line number.
9. PHPUnit Integration#
If your team already uses PHPUnit, Tina4 tests can run alongside it. Install PHPUnit via Composer:
composer require --dev phpunit/phpunitCreate phpunit.xml at the project root:
<?xml version="1.0" encoding="UTF-8"?><phpunit bootstrap="vendor/autoload.php" colors="true" stopOnFailure="false"> <testsuites> <testsuite name="Application"> <directory>tests</directory> </testsuite> </testsuites></phpunit>Tina4's Test class is compatible with PHPUnit. Your test files work with both tina4 test and vendor/bin/phpunit without modification.
vendor/bin/phpunitPHPUnit 10.5.0 by Sebastian Bergmann and contributors.โ..... 5 / 5 (100%)โTime: 00:00.340, Memory: 8.00 MBโOK (5 tests, 12 assertions)Code Coverage#
With PHPUnit, you can generate code coverage reports:
vendor/bin/phpunit --coverage-textCode Coverage Report: Summary: Classes: 60.00% (3/5) Methods: 75.00% (15/20) Lines: 82.14% (115/140)โ Product Methods: 100.00% ( 5/ 5) Lines: 100.00% ( 28/ 28) User Methods: 66.67% ( 4/ 6) Lines: 78.95% ( 30/ 38)For HTML coverage reports:
vendor/bin/phpunit --coverage-html coverage/Open coverage/index.html in your browser to see a visual breakdown of which lines are covered by tests.
Note: Code coverage requires the Xdebug or PCOV extension. Install one of them:
# Xdebugpecl install xdebugโ# PCOV (faster for coverage only)pecl install pcov10. Testing Best Practices#
Test One Thing Per Test#
Each test method verifies one behavior. When it fails, you know what broke.
// Good: each test verifies one thingpublic function testCreateProductReturns201(){ $response = $this->post("/api/products", ["name" => "Widget", "price" => 9.99]); $this->assertEqual($response->status, 201, "Should return 201");}โpublic function testCreateProductReturnsCreatedProduct(){ $response = $this->post("/api/products", ["name" => "Widget", "price" => 9.99]); $body = json_decode($response->body, true); $this->assertEqual($body["name"], "Widget", "Should return the product name");}โ// Avoid: testing multiple unrelated thingspublic function testEverything(){ // Creates, reads, updates, deletes, checks auth, validates input... // When this fails, you don't know which part broke}Use Descriptive Assertion Messages#
// Good: tells you what went wrong$this->assertEqual($user->name, "Alice", "User name should be 'Alice' after creation");โ// Bad: unhelpful when it fails$this->assertEqual($user->name, "Alice", "Failed");Isolate Tests#
Each test creates its own data and cleans up after itself. Never depend on data from another test or from the development database.
// Good: creates its own datapublic function testDeleteProduct(){ $product = new Product(); $product->name = "Temporary"; $product->price = 1.00; $product->save();โ $product->delete();โ $check = new Product(); $check->load($product->id); $this->assertTrue(empty($check->id), "Product should be deleted");}โ// Bad: depends on data from another test or the dev databasepublic function testDeleteProduct(){ $product = new Product(); $product->load(1); // Assumes product with ID 1 exists $product->delete();}11. Exercise: Test a User Model and Authentication Flow#
Write a complete test suite for a User model and authentication system.
Requirements#
Create tests/UserModelTest.php with these tests:
- testCreateUser -- Create a user with name, email. Verify the user gets an ID and all fields are correct.
- testDuplicateEmail -- Try to create two users with the same email. Verify the second one raises an exception or returns an error.
- testUpdateUser -- Create a user, update their name, reload and verify.
- testDeleteUser -- Create a user, delete them, verify they cannot be loaded.
- testSelectUsers -- Create 3 users, query all, verify count is at least 3.
Create tests/AuthFlowTest.php with these tests:
- testRegisterNewUser -- POST to
/api/auth/registerwith name, email, password. Verify 201 status. - testRegisterDuplicateEmail -- Register the same email twice. Verify the second returns 409 Conflict.
- testLoginSuccess -- Login with correct credentials. Verify you get a JWT token.
- testLoginFailure -- Login with wrong password. Verify 401 status.
- testAccessProtectedRoute -- Use the token from login to access
/api/profile. Verify 200 status and correct user data. - testAccessWithExpiredToken -- Use a manually crafted expired token. Verify 401 status.
Expected output:#
tina4 testRunning tests...โ UserModelTest [PASS] test_create_user [PASS] test_duplicate_email [PASS] test_update_user [PASS] test_delete_user [PASS] test_select_usersโ AuthFlowTest [PASS] test_register_new_user [PASS] test_register_duplicate_email [PASS] test_login_success [PASS] test_login_failure [PASS] test_access_protected_route [PASS] test_access_with_expired_tokenโ 11 tests, 11 passed, 0 failed (0.52s)12. Solution#
tests/UserModelTest.php#
<?phpuse Tina4\Test;โclass UserModelTest extends Test{ public function testCreateUser() { $user = new User(); $user->name = "Test User"; $user->email = "testuser-" . uniqid() . "@example.com"; $user->save();โ $this->assertNotNull($user->id, "User should have an ID after save"); $this->assertEqual($user->name, "Test User", "Name should match"); $this->assertTrue(str_contains($user->email, "@example.com"), "Email should be set"); }โ public function testDuplicateEmail() { $email = "duplicate-" . uniqid() . "@example.com";โ $user1 = new User(); $user1->name = "First User"; $user1->email = $email; $user1->save();โ $this->assertRaises(function () use ($email) { $user2 = new User(); $user2->name = "Second User"; $user2->email = $email; $user2->save(); }, \Exception::class, "Should reject duplicate email"); }โ public function testUpdateUser() { $user = new User(); $user->name = "Original Name"; $user->email = "update-" . uniqid() . "@example.com"; $user->save();โ $id = $user->id; $user->name = "New Name"; $user->save();โ $reloaded = new User(); $reloaded->load($id); $this->assertEqual($reloaded->name, "New Name", "Name should be updated"); }โ public function testDeleteUser() { $user = new User(); $user->name = "Delete Me"; $user->email = "delete-" . uniqid() . "@example.com"; $user->save();โ $id = $user->id; $user->delete();โ $gone = new User(); $gone->load($id); $this->assertTrue(empty($gone->id), "Deleted user should not exist"); }โ public function testSelectUsers() { for ($i = 0; $i < 3; $i++) { $user = new User(); $user->name = "Select Test User " . $i; $user->email = "select-test-" . $i . "-" . uniqid() . "@example.com"; $user->save(); }โ $user = new User(); $results = $user->select("*");โ $this->assertTrue(count($results) >= 3, "Should have at least 3 users"); }}tests/AuthFlowTest.php#
<?phpuse Tina4\Test;โclass AuthFlowTest extends Test{ private string $testEmail; private string $testPassword = "SecurePassword123!";โ public function setUp(): void { $this->testEmail = "auth-test-" . uniqid() . "@example.com"; }โ public function testRegisterNewUser() { $response = $this->post("/api/auth/register", [ "name" => "Auth Test User", "email" => $this->testEmail, "password" => $this->testPassword ]);โ $this->assertEqual($response->status, 201, "Registration should return 201");โ $body = json_decode($response->body, true); $this->assertEqual($body["name"], "Auth Test User", "Should return user name"); $this->assertNotNull($body["id"], "Should return user ID"); }โ public function testRegisterDuplicateEmail() { $email = "dup-" . uniqid() . "@example.com";โ // Register first time $this->post("/api/auth/register", [ "name" => "First", "email" => $email, "password" => $this->testPassword ]);โ // Register again with same email $response = $this->post("/api/auth/register", [ "name" => "Second", "email" => $email, "password" => $this->testPassword ]);โ $this->assertEqual($response->status, 409, "Duplicate email should return 409"); }โ public function testLoginSuccess() { $email = "login-" . uniqid() . "@example.com";โ // Register $this->post("/api/auth/register", [ "name" => "Login User", "email" => $email, "password" => $this->testPassword ]);โ // Login $response = $this->post("/api/auth/login", [ "email" => $email, "password" => $this->testPassword ]);โ $this->assertEqual($response->status, 200, "Login should return 200");โ $body = json_decode($response->body, true); $this->assertNotNull($body["token"], "Should return a token"); }โ public function testLoginFailure() { $response = $this->post("/api/auth/login", [ "email" => "nobody@example.com", "password" => "wrong" ]);โ $this->assertEqual($response->status, 401, "Invalid login should return 401"); }โ public function testAccessProtectedRoute() { $email = "profile-" . uniqid() . "@example.com";โ // Register and login $this->post("/api/auth/register", [ "name" => "Profile User", "email" => $email, "password" => $this->testPassword ]);โ $loginResponse = $this->post("/api/auth/login", [ "email" => $email, "password" => $this->testPassword ]);โ $token = json_decode($loginResponse->body, true)["token"];โ // Access protected route $response = $this->get("/api/profile", [ "Authorization" => "Bearer " . $token ]);โ $this->assertEqual($response->status, 200, "Should allow access with valid token");โ $body = json_decode($response->body, true); $this->assertEqual($body["user"]["email"], $email, "Should return correct user"); }โ public function testAccessWithExpiredToken() { $response = $this->get("/api/profile", [ "Authorization" => "Bearer expired.invalid.token" ]);โ $this->assertEqual($response->status, 401, "Should reject invalid token"); }}13. Gotchas#
1. Tests Run in Order#
Problem: Test B depends on data created in Test A, but sometimes Test B fails.
Cause: While tests within a class run in the order they are defined, test classes may run in any order. If Test B depends on Test A being in a different class, this is fragile.
Fix: Each test should create its own data. Never depend on another test's side effects. Use setUp() to create the data each test needs.
2. Test Database vs Development Database#
Problem: Running tests deletes your development data.
Cause: Tests are running against the same database as your development server.
Fix: Tina4 uses a separate test database by default (data/test.db). If your tests are modifying development data, check that TINA4_DATABASE_URL is set correctly in .env, or that you are running tina4 test (not executing test files manually).
3. Unique Constraint Failures#
Problem: Tests fail with "UNIQUE constraint failed" on the email field.
Cause: Previous test runs left data in the test database, or two tests create records with the same email.
Fix: Use uniqid() or time() to generate unique values in tests:
$user->email = "test-" . uniqid() . "@example.com";4. Test Method Not Discovered#
Problem: You wrote a test method but it does not appear in the output.
Cause: The method name does not start with test. Tina4 only runs methods that begin with test (case-sensitive).
Fix: Rename the method to start with test: testCreateProduct, testLoginFailure, etc.
5. Assertion Arguments Reversed#
Problem: The failure message shows "Expected: 404, Actual: 200" but that seems backward.
Cause: You passed the arguments in the wrong order. assertEqual($actual, $expected) -- actual comes first, expected comes second.
Fix: Follow the convention: $this->assertEqual($response->status, 200, "message"). The first argument is what you got, the second is what you expected.
6. Cannot Test Routes That Require Authentication Setup#
Problem: Tests for protected routes fail because the authentication middleware expects a database table or JWT secret that does not exist in the test environment.
Cause: The test database is empty -- it does not have the users table or the JWT secret is not configured.
Fix: Use setUp() to create the necessary data:
public function setUp(): void{ // Create the users table if it does not exist $user = new User(); $user->createTable();โ // Create a test user $user->name = "Test Admin"; $user->email = "admin@test.com"; $user->passwordHash = password_hash("password", PASSWORD_DEFAULT); $user->save();}7. Tests Pass Locally but Fail in CI#
Problem: All tests pass on your machine but fail in continuous integration.
Cause: The CI environment may have a different PHP version, missing extensions, or different filesystem permissions. Also, tests that depend on time or random values can be flaky.
Fix: Make tests deterministic. Avoid relying on the current time, filesystem state, or external services. If you must test time-dependent behavior, mock the clock or use fixed timestamps. Check that your CI environment matches your local PHP version and has all required extensions.