Testing
The Fuwafuwa Framework includes a comprehensive testing framework built on PHPUnit with support for unit tests, integration tests, and feature tests.
Testing is essential for building reliable, maintainable applications. The framework provides a complete testing toolkit that integrates seamlessly with your development workflow. From fast unit tests that verify individual components, to integration tests that check database operations, to feature tests that validate complete workflows, you have everything needed to ensure code quality.
The testing framework goes beyond basic PHPUnit by adding specialized test cases, database assertions, test data factories, and fixtures. These tools make writing tests faster and more enjoyable, encouraging developers to embrace test-driven development. Pre-commit hooks and CI/CD integration ensure tests run automatically, catching issues before they reach production.
- Multiple Test Types - Unit, Integration, and Feature test support for comprehensive coverage
- Database Assertions - Specialized helpers for verifying database state
- HTTP Simulation - Test controllers and endpoints without a web server
- Test Data Factories - Generate realistic test data with minimal code
- Fixtures System - Predefined test data for common scenarios
- Code Coverage - HTML reports showing which code is tested
- Pre-commit Hooks - Automatic testing before each commit
Running Tests
The framework provides multiple ways to run tests, depending on your workflow and what you want to test. Run all tests for a complete check, or target specific test suites for faster feedback during development. NPM scripts provide convenient shortcuts, while direct PHPUnit calls offer maximum control.
Run All Tests
# Via npm
npm test
# Via gulp
npx gulp test
# Direct
vendor/bin/phpunit
Running all tests gives you complete confidence that your application works as expected. Use this before committing code or deploying to production. The test suite runs unit, integration, and feature tests in order, with fastest tests running first.
Run Specific Test Suites
# Unit tests only
npm run test:unit
npx gulp test:unit
# Integration tests only
npm run test:integration
npx gulp test:integration
# Feature tests only
npm run test:feature
npx gulp test:feature
During active development, running only the relevant test suite saves time. Unit tests run fastest and are ideal for testing business logic in isolation. Integration tests verify database operations and framework integration. Feature tests test complete workflows but run more slowly.
Generate Coverage Report
npm run test:coverage
npx gulp test:coverage
Coverage reports are generated in the coverage/ directory.
Code coverage reports show which parts of your code are executed during tests. High coverage doesn't guarantee bug-free code, but it does highlight untested areas that may harbor issues. Aim for meaningful coverage of critical business logic rather than hitting an arbitrary percentage target.
Test Structure
The test directory follows a clear organizational structure that separates different types of tests and provides shared utilities. This organization keeps tests maintainable and makes it easy to find what you're looking for as the test suite grows.
Unit tests go in tests/unit/ and test individual components in isolation. Integration
tests in tests/integration/ verify that components work together with the database.
Feature tests in tests/feature/ test complete user workflows. Shared utilities like traits,
factories, and fixtures have their own directories for reuse across all test types.
tests/
├── unit/ # Unit tests (isolated, no DB)
│ ├── BaseModelTest.php
│ ├── ControllerTest.php
│ └── ...
├── integration/ # Integration tests (with DB/framework)
│ ├── DatabaseIntegrationTest.php
│ ├── FrameworkIntegrationTest.php
│ └── ...
├── feature/ # Feature tests (full stack workflows)
│ ├── CrudWorkflowFeatureTest.php
│ ├── AuthenticationFlowFeatureTest.php
│ └── ...
├── Traits/ # Reusable test traits
│ ├── WithDatabaseAssertions.php
│ ├── WithHttpAssertions.php
│ └── ...
├── Factories/ # Test data factories
│ ├── Factory.php
│ ├── UserFactory.php
│ └── ...
├── Fixtures/ # Test data fixtures
│ └── Fixtures.php
├── fixtures/ # Fixture data files
│ ├── user_fixtures.php
│ └── ...
├── bootstrap.php # Test bootstrap
└── testcase.php # Base test case
Unit Tests
Unit tests verify that individual components—functions, classes, methods—work correctly in isolation. They're fast to run and don't interact with the database, filesystem, or external services. This makes them ideal for testing business logic, data transformations, validation rules, and other pure functions.
Because unit tests don't touch external systems, they're deterministic and predictable. Given the same input, they always produce the same output. This makes them excellent for regression testing—when code breaks, unit tests pinpoint exactly where the failure occurred. Write unit tests for any code that can be tested without framework dependencies.
<?php
declare(strict_types=1);
namespace Tests\unit;
use Tests\TestCase;
class MyComponentTest extends TestCase
{
public function testSomething(): void
{
// Arrange
$expected = 'result';
// Act
$actual = someFunction();
// Assert
$this->assertEquals($expected, $actual);
}
}
The Arrange-Act-Assert pattern (AAA) keeps tests readable and organized. First arrange the test conditions—create objects, set up data. Then act—call the method being tested. Finally assert—verify the result matches expectations. This consistent structure makes tests easier to understand and maintain.
Unit Test Best Practices
- Extend
Tests\TestCase - Don't use the database (use
$this->getTestDb()only if needed) - Test in isolation (no framework dependencies)
- Use descriptive test names (
testCanCreateUsernottestUser)
Integration Tests
Integration tests verify that multiple components work together correctly. They interact with the database, use the framework's services, and test the connections between different parts of your application. Integration tests are slower than unit tests but catch issues that unit tests can't, such as SQL errors, relationship problems, and framework integration bugs.
Use integration tests to verify database queries model operations, framework service interactions, and API endpoint responses. They're particularly important for database-related code, where subtle bugs like incorrect SQL syntax, missing indexes, or transaction issues can cause production problems.
<?php
declare(strict_types=1);
namespace Tests\integration;
use Tests\IntegrationTestCase;
class MyIntegrationTest extends IntegrationTestCase
{
private object $db;
protected function setUp(): void
{
parent::setUp();
$this->db = $this->getTestDb();
}
public function testDatabaseOperation(): void
{
// Create a record
$this->db->exec("INSERT INTO users (name) VALUES ('Test')");
// Verify it was created
$result = $this->db->exec('SELECT * FROM users WHERE name = ?', ['Test']);
$this->assertCount(1, $result);
}
}
Best Practices
- Extend
Tests\IntegrationTestCase - Clean up test data in
tearDown() - Use transactions or create/drop tables for isolation
- Test database queries, framework integration
Feature Tests
Feature tests (also called end-to-end tests) verify complete user workflows from start to finish. They simulate real user actions—creating records, submitting forms, navigating pages—to ensure the entire system works together. Feature tests are the slowest but most comprehensive type of test, catching issues that only appear when all components interact.
Use feature tests to validate critical user workflows like user registration, checkout processes, or data import/export. While they're more expensive to write and maintain than unit tests, they provide confidence that core functionality works end-to-end. A small set of well-chosen feature tests can catch integration issues that slip through unit and integration testing.
<?php
declare(strict_types=1);
namespace Tests\feature;
use Tests\FeatureTestCase;
class MyFeatureTest extends FeatureTestCase
{
private object $db;
protected function setUp(): void
{
parent::setUp();
$this->db = $this->getTestDb();
}
public function testCompleteWorkflow(): void
{
// Create
$this->db->exec("INSERT INTO items (name) VALUES ('Test')");
$id = (int) $this->db->lastInsertId();
// Read
$result = $this->db->exec('SELECT * FROM items WHERE id = ?', [$id]);
$this->assertEquals('Test', $result[0]['name']);
// Update
$this->db->exec('UPDATE items SET name = ? WHERE id = ?', ['Updated', $id]);
$result = $this->db->exec('SELECT name FROM items WHERE id = ?', [$id]);
$this->assertEquals('Updated', $result[0]['name']);
// Delete
$this->db->exec('DELETE FROM items WHERE id = ?', [$id]);
$result = $this->db->exec('SELECT id FROM items WHERE id = ?', [$id]);
$this->assertEmpty($result);
}
}
Feature tests should focus on user-visible behavior rather than implementation details. Test what matters to users—can they create an account, submit an order, view their profile—rather than how the code achieves it internally. This makes tests more resilient to refactoring while still catching regressions that affect user experience.
Database Assertions
Database tests often require verbose SQL queries to verify results. The WithDatabaseAssertions
trait provides a cleaner, more expressive way to assert database state. Instead of writing SELECT
queries and checking counts manually, use declarative assertions that describe what you expect to find.
Use the WithDatabaseAssertions trait for database testing:
use Tests\Traits\WithDatabaseAssertions;
class MyTest extends TestCase
{
use WithDatabaseAssertions;
public function testDatabaseHasRecord(): void
{
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'notfound@example.com']);
$this->assertDatabaseCount('users', 5);
$this->assertDatabaseEmpty('temp_table');
}
}
Available Assertions
| Assertion | Description |
|---|---|
assertDatabaseHas($table, $data) | Check if record exists |
assertDatabaseMissing($table, $data) | Check if record doesn't exist |
assertDatabaseCount($table, $count) | Check record count |
assertDatabaseEmpty($table) | Check if table is empty |
assertDatabaseHasAtLeast($table, $count) | Minimum count check |
assertDatabaseHasId($table, $id) | Check by ID |
assertTableHasColumns($table, $columns) | Verify table structure |
assertTableExists($table) | Check table exists |
These assertions encapsulate common database testing patterns, making tests more readable and less error-prone. Each assertion handles the SQL query and comparison logic internally, so you can focus on what you're testing rather than how to query the database.
Test Factories
Writing test data by hand is tedious and error-prone. Factories generate realistic test data with sensible defaults, while still allowing customization when needed. They ensure unique values for required fields like email addresses and usernames, preventing duplicate key errors during tests.
Generate test data with factories:
use Tests\Factories\UserFactory;
use Tests\Factories\ProductFactory;
// Create a user
$user = UserFactory::make();
// ['username' => 'user_abc123', 'email' => 'user_abc123@example.com', ...]
// Create with overrides
$admin = UserFactory::admin(['email' => 'admin@example.com']);
// Create multiple
$users = UserFactory::makeMany(10);
// Products
$product = ProductFactory::make();
$premium = ProductFactory::premium();
$outOfStock = ProductFactory::outOfStock();
Factory methods like admin(), premium(), and outOfStock() define
presets for specific test scenarios. These named factory methods make tests more readable by clearly
expressing the intent—creating an admin user, a premium product, or an out-of-stock item—rather
than burying these details in array configurations.
Fixtures
While factories generate data programmatically, fixtures provide predefined test data stored in files. Fixures are ideal for reference data that doesn't change during tests—user roles, product categories, configuration settings. They keep test data organized and reusable across multiple tests.
Load predefined test data:
use Tests\Fixtures\Fixtures;
// Load all fixtures
$users = Fixtures::all('user');
// Get specific fixture
$admin = Fixtures::get('user', 'admin');
// ['username' => 'admin', 'email' => 'admin@example.com', ...]
// Check if exists
if (Fixtures::has('product', 'premium')) {
$product = Fixtures::get('product', 'premium');
}
Fixtures shine when multiple tests need the same baseline data. Rather than recreating test data in every test, define it once as a fixture and load it wherever needed. This DRY (Don't Repeat Yourself) approach makes tests easier to maintain—update the fixture, and all tests using it automatically get the new data.
Best Practices
Writing good tests is a skill that improves with practice. Following these best practices will make your tests more reliable, maintainable, and valuable as safeguards against regressions.
1. Use Descriptive Test Names
- Good:
testCanCreateUserWithValidData - Bad:
testUser
Descriptive test names serve as documentation. When a test fails, the name should immediately tell what broke. Long test names are good—testCanCreateUserWithValidData is better than testUser1. Consider reading the test name as a sentence: "It should..." or "It can..." helps frame names descriptively.
2. Follow the Arrange-Act-Assert Pattern
public function testUserCreation(): void
{
// Arrange
$data = ['name' => 'Test'];
// Act
$result = createUser($data);
// Assert
$this->assertEquals('Test', $result->name);
}
The AAA pattern (Arrange-Act-Assert) creates a clear structure that makes tests easy to read and understand. Arrange sets up the test context—create objects, set variables. Act performs the operation being tested. Assert verifies the outcome. This consistency helps others (and future you) quickly grasp what each test does.
3. Clean Up After Tests
protected function tearDown(): void
{
$this->db->exec('DROP TABLE IF EXISTS test_table');
parent::tearDown();
}
Tests should clean up after themselves to avoid polluting the test database or affecting subsequent tests. Use tearDown() to delete test records, drop temporary tables, or reset static state. Some developers prefer transaction rollback for faster cleanup—wrap the test in a transaction and roll back instead of deleting records individually.
4. Use Factories for Test Data
- Avoid hardcoded test data
- Factories ensure unique values
Hardcoded test data is brittle—change a required field validation rule, and suddenly ten tests fail because they all used the same hardcoded email address. Factories generate unique values automatically, making tests more resilient to change. Plus, factories are more readable than large array literals.
5. Test One Thing Per Test
- Each test should verify a single behavior
- Use
@testannotation for long test names
Tests that verify multiple behaviors are harder to understand and maintain. When a multi-purpose test fails, it's unclear what broke. Split such tests into individual tests, each verifying one specific behavior. This makes failures more pinpointed and tests more readable—the test name tells you exactly what scenario is being tested.
CI/CD Integration
Automated testing is most valuable when it runs automatically—before commits, in CI pipelines, and during deployments. The framework includes pre-commit hooks and NPM scripts for easy integration into your development workflow.
Pre-commit Hook
The framework includes a pre-commit hook that runs tests before each commit:
# Install the hook
./install-git-hooks.sh
# Bypass if needed
git commit --no-verify -m "message"
Pre-commit hooks catch issues before they enter your repository, preventing broken code from being
committed. The hook runs the full test suite and blocks commits if any tests fail. For emergency
commits when you know what you're doing, use --no-verify to bypass the hook.
NPM Scripts
{
"scripts": {
"test": "vendor/bin/phpunit",
"test:coverage": "vendor/bin/phpunit --coverage-html coverage",
"test:dox": "vendor/bin/phpunit --testdox",
"test:unit": "vendor/bin/phpunit tests/unit",
"test:integration": "vendor/bin/phpunit tests/integration",
"test:feature": "vendor/bin/phpunit tests/feature"
}
}
NPM scripts provide convenient shortcuts for common testing tasks. They're easier to remember than direct PHPUnit commands and can be used consistently across different environments. Add your own custom scripts to package.json for project-specific testing workflows.
Troubleshooting
Testing issues are common but usually have straightforward solutions. Here are answers to frequent problems.
Tests Fail with "No such table"
The test database needs migrations. Run migrations on the test database:
sqlite3 app/db/test.db < migrations/001_create_users_sqlite.sql
This error occurs when tests try to access database tables that don't exist in the test database. The test database is separate from the development database and needs its own migrations. Run all migration scripts against the test database to create the required schema.
PHPUnit Exit Code 1 with No Failures
This is usually due to missing Xdebug for code coverage. Tests pass but PHPUnit warns about missing coverage driver. The pre-commit hook handles this correctly by checking for "OK" in output.
Migration Errors During Tests
Some migrations may reference tables in the main database. Use RefreshDatabase trait or create/drop tables in setUp/tearDown.
When debugging failing tests, use vendor/bin/phpunit --testdox for readable output,
or vendor/bin/phpunit --filter testName to run a specific test. The -v
flag increases verbosity, showing which tests are running and their results.