Why PHPUnit Remains the Testing Backbone in Laravel 12
The Laravel Testing Architecture
Unit Tests: Testing in Isolation
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
/**
* Test price calculation with discount.
*
* @return void
*/
public function test_calculates_discounted_price_correctly(): void
{
$originalPrice = 100.00;
$discountPercent = 20;
$calculator = new \App\Services\PriceCalculator();
$finalPrice = $calculator->applyDiscount($originalPrice, $discountPercent);
$this->assertEquals(80.00, $finalPrice);
}
/**
* Test that negative discount throws exception.
*
* @return void
*/
public function test_throws_exception_for_negative_discount(): void
{
$this->expectException(\InvalidArgumentException::class);
$calculator = new \App\Services\PriceCalculator();
$calculator->applyDiscount(100.00, -10);
}
}
Feature Tests: The Real-World Scenarios
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
class UserRegistrationTest extends TestCase
{
use RefreshDatabase;
/**
* Test successful user registration.
*
* @return void
*/
public function test_user_can_register_with_valid_data(): void
{
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'SecurePass123!',
'password_confirmation' => 'SecurePass123!',
]);
$response->assertStatus(302);
$response->assertRedirect('/dashboard');
$this->assertAuthenticated();
$this->assertDatabaseHas('users', [
'email' => '[email protected]'
]);
}
/**
* Test registration fails with duplicate email.
*
* @return void
*/
public function test_registration_fails_with_duplicate_email(): void
{
// Create existing user
User::factory()->create(['email' => '[email protected]']);
$response = $this->post('/register', [
'name' => 'Jane Doe',
'email' => '[email protected]',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertSessionHasErrors('email');
}
}
Running Your Tests
# Method 1: Direct PHPUnit command
vendor/bin/phpunit
# Method 2: Artisan test command (recommended)
php artisan test
# Method 3: Run specific test suite
php artisan test --testsuite=Feature
# Stop on first failure
php artisan test --stop-on-failure
# Run specific test file
php artisan test tests/Feature/UserRegistrationTest.php
# Filter tests by name
php artisan test --filter=registration
Database Testing
Setting Up Test Database
Option 1: SQLite In-Memory Database (Fastest)
phpunit.xml, uncomment these lines: <env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
Option 2: Separate MySQL Database
.env.testing in your project root: DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_app_test
DB_USERNAME=root
DB_PASSWORD=
Using Model Factories
/**
* Test authenticated user can create post.
*
* @return void
*/
public function test_authenticated_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/posts', [
'title' => 'My First Post',
'content' => 'This is the post content.',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('posts', [
'title' => 'My First Post',
'user_id' => $user->id,
]);
}
Database Assertions: Verify Your Data
/**
* Test product is created in database.
*
* @return void
*/
public function test_product_is_stored_in_database(): void
{
$product = Product::create([
'name' => 'Laptop',
'price' => 999.99,
'stock' => 10,
]);
// Assert record exists with specific data
$this->assertDatabaseHas('products', [
'name' => 'Laptop',
'price' => 999.99,
]);
// Assert total count in table
$this->assertDatabaseCount('products', 1);
}
Explore project snapshots or discuss custom solutions.
Testing HTTP Responses and JSON APIs
Testing JSON APIs
public function test_api_returns_product_list(): void
{
$products = Product::factory()->count(5)->create();
$response = $this->getJson('/api/products');
$response->assertStatus(200)
->assertJsonCount(5, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'price', 'created_at']
]
]);
}
/**
* Test API creates product with valid data.
*
* @return void
*/
public function test_api_creates_product_with_valid_data(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user, 'api')
->postJson('/api/products', [
'name' => 'New Product',
'price' => 49.99,
'stock' => 100,
]);
$response->assertStatus(201)
->assertJson([
'name' => 'New Product',
'price' => 49.99,
]);
$this->assertDatabaseHas('products', [
'name' => 'New Product',
]);
}
Authentication Testing Made Simple
/**
* Test user can login with valid credentials.
*
* @return void
*/
public function test_user_can_login_with_valid_credentials(): void
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('password123'),
]);
$response = $this->post('/login', [
'email' => '[email protected]',
'password' => 'password123',
]);
$response->assertStatus(302);
$this->assertAuthenticated();
$response->assertRedirect('/dashboard');
}
/**
* Test guest cannot access protected route.
*
* @return void
*/
public function test_guest_cannot_access_protected_route(): void
{
$response = $this->get('/dashboard');
$response->assertStatus(302);
$response->assertRedirect('/login');
$this->assertGuest();
}
Testing HTML Views
/**
* Test product listing page displays products.
*
* @return void
*/
public function test_product_page_displays_products(): void
{
$products = Product::factory()->count(3)->create();
$response = $this->get('/products');
$response->assertStatus(200);
foreach ($products as $product) {
$response->assertSee($product->name);
$response->assertSee(number_format($product->price, 2));
}
}
Practical Tips for Laravel 12 Testing
Use Descriptive Test Method Names
// Good
public function test_user_cannot_purchase_out_of_stock_product(): void
// Less clear
public function testPurchase(): void
Organize Tests by Feature
tests/
├── Feature/
│ ├── Auth/
│ │ ├── LoginTest.php
│ │ └── RegistrationTest.php
│ ├── Products/
│ │ ├── ProductCreationTest.php
│ │ └── ProductListingTest.php
│ └── Orders/
│ └── CheckoutTest.php
└── Unit/
├── Services/
│ └── PriceCalculatorTest.php
└── Models/
└── UserTest.php
Leverage setUp() and tearDown() Methods
/**
* Set up test environment before each test.
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create([
'role' => 'admin',
]);
$this->actingAs($this->user);
}
/**
* Test admin can view dashboard.
*
* @return void
*/
public function test_admin_can_view_dashboard(): void
{
$response = $this->get('/admin/dashboard');
$response->assertStatus(200);
}
Test File Uploads
<?php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_user_can_upload_avatar(): void
{
Storage::fake('avatars');
$file = UploadedFile::fake()->image('avatar.jpg', 600, 600);
$response = $this->actingAs($this->user)
->post('/profile/avatar', [
'avatar' => $file
]);
$response->assertStatus(200);
Storage::disk('avatars')->assertExists($file->hashName());
}
The Testing Mindset
Testing shows the presence, not the absence of bugs.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
Laravel and PHPUnit can run tests simultaneously across multiple processes by including the --parallel option. First, install ParaTest: `composer require brianium/paratest --dev`, then run: `php artisan test --parallel`. Laravel automatically creates separate test databases for each process (e.g., `your_db_test_1`, `your_db_test_2`). On a 4-core machine, this can reduce test suite time by 60-75%.
For most scenarios, use SQLite in-memory databases. SQLite can be configured in phpunit.xml by uncommenting the DB_CONNECTION and DB_DATABASE settings. In-memory SQLite is significantly faster (tests run 3-5x quicker) and doesn't require separate database setup. Use MySQL only when testing MySQL-specific features like full-text search or stored procedures that behave differently across database engines.
According to PHPUnit documentation, `assertEquals()` compares values with type juggling (loose comparison: `100 == "100"` is true), while `assertSame()` requires identical types and values (strict comparison: `100 === "100"` is false). Use `assertSame()` when types matter, especially for booleans, null checks, or when you need strict equality. Use `assertEquals()` for numeric or string comparisons where type coercion is acceptable.
Laravel provides time manipulation methods in tests. Use `$this->travel(5)->days()` to move time forward 5 days, or `$this->freezeTime()` to lock the current time. This allows testing time-dependent logic like subscription expiration, trial periods, or scheduled events without waiting for actual time to pass. After your test, time automatically resets.
Run tests frequently—ideally after every significant code change. According to Better Stack's Laravel testing guide, you can set up continuous integration workflows that automatically run tests on every push or pull request. Locally, run tests before committing code. Use `--filter` to run specific tests during active development: `php artisan test --filter=product`. Full test suites should run before deployment and in CI/CD pipelines.
Comments are closed