Untested code is broken code. At ZIRA Software, we've seen countless projects fail because testing was treated as optional. Laravel's built-in PHPUnit integration makes testing straightforward, and there's no excuse for shipping untested code in 2014.
Why Test Your Laravel Applications?
The business case for testing:
- Catch bugs before users do
- Refactor with confidence
- Document expected behavior
- Reduce debugging time by 60%+
- Enable continuous deployment
The cost of not testing:
- Production bugs that could have been caught
- Fear of changing code
- Slower development velocity over time
- Higher maintenance costs
Setting Up PHPUnit
Laravel 4 includes PHPUnit out of the box. Your phpunit.xml configuration:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="bootstrap/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false">
<testsuites>
<testsuite name="Application Test Suite">
<directory>./app/tests/</directory>
</testsuite>
</testsuites>
</phpunit>
Run tests:
phpunit
Writing Your First Test
Create a test file: app/tests/UserTest.php
<?php
class UserTest extends TestCase {
public function testUserCreation()
{
$user = new User;
$user->name = 'John Doe';
$user->email = 'john@example.com';
$user->password = Hash::make('password');
$user->save();
$this->assertTrue($user->exists);
$this->assertEquals('John Doe', $user->name);
}
}
Run it:
phpunit --filter testUserCreation
Test Structure: Arrange, Act, Assert
Follow the AAA pattern for clear, maintainable tests:
public function testUserCanPublishPost()
{
// Arrange - Set up test data
$user = User::create([
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => Hash::make('password')
]);
// Act - Perform the action
$post = $user->posts()->create([
'title' => 'My First Post',
'body' => 'This is the content'
]);
// Assert - Verify the outcome
$this->assertInstanceOf('Post', $post);
$this->assertEquals($user->id, $post->user_id);
$this->assertCount(1, $user->posts);
}
Testing Models
Basic Model Tests
class PostTest extends TestCase {
public function testPostBelongsToUser()
{
$user = User::create([
'name' => 'John',
'email' => 'john@test.com',
'password' => 'secret'
]);
$post = Post::create([
'user_id' => $user->id,
'title' => 'Test Post',
'body' => 'Content'
]);
$this->assertInstanceOf('User', $post->user);
$this->assertEquals('John', $post->user->name);
}
public function testPostHasSlug()
{
$post = new Post;
$post->title = 'My Great Post';
$post->generateSlug();
$this->assertEquals('my-great-post', $post->slug);
}
}
Testing Validation
public function testPostRequiresTitle()
{
$post = new Post;
$post->body = 'Some content';
$validator = Validator::make($post->toArray(), Post::$rules);
$this->assertTrue($validator->fails());
$this->assertArrayHasKey('title', $validator->messages()->toArray());
}
public function testPostTitleMustBeUnique()
{
Post::create(['title' => 'Unique Title', 'body' => 'Content']);
$post = new Post(['title' => 'Unique Title', 'body' => 'More content']);
$validator = Validator::make($post->toArray(), Post::$rules);
$this->assertTrue($validator->fails());
}
Testing Controllers
Route Testing
class HomeControllerTest extends TestCase {
public function testHomePageLoads()
{
$response = $this->call('GET', '/');
$this->assertTrue($response->isOk());
$this->assertViewHas('posts');
}
public function testAboutPageLoads()
{
$response = $this->call('GET', '/about');
$this->assertResponseOk();
}
}
Testing Authentication
public function testGuestCannotAccessDashboard()
{
$response = $this->call('GET', '/dashboard');
$this->assertRedirectedToRoute('login');
}
public function testAuthenticatedUserCanAccessDashboard()
{
$user = User::create([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => Hash::make('password')
]);
$this->be($user); // Authenticate as this user
$response = $this->call('GET', '/dashboard');
$this->assertResponseOk();
$this->assertViewHas('user');
}
Testing Form Submissions
public function testUserCanCreatePost()
{
$user = User::create([
'name' => 'Author',
'email' => 'author@example.com',
'password' => Hash::make('password')
]);
$this->be($user);
$response = $this->call('POST', '/posts', [
'title' => 'New Post',
'body' => 'Post content goes here'
]);
$this->assertRedirectedToRoute('posts.show', ['id' => 1]);
$post = Post::find(1);
$this->assertEquals('New Post', $post->title);
$this->assertEquals($user->id, $post->user_id);
}
Database Testing
Using Transactions
Wrap tests in database transactions to keep your test database clean:
class PostTest extends TestCase {
public function setUp()
{
parent::setUp();
DB::beginTransaction();
}
public function tearDown()
{
DB::rollback();
parent::tearDown();
}
public function testSomething()
{
// Your test that modifies the database
// Changes will be rolled back automatically
}
}
Database Migrations in Tests
class DatabaseTest extends TestCase {
public function setUp()
{
parent::setUp();
Artisan::call('migrate');
}
public function tearDown()
{
Artisan::call('migrate:reset');
parent::tearDown();
}
}
Using Factories (Laravel 4.1+)
// database/factories.php
$factory->define('User', function($faker) {
return [
'name' => $faker->name,
'email' => $faker->email,
'password' => Hash::make('password')
];
});
// In your test
public function testUserFactory()
{
$user = factory('User')->create();
$this->assertTrue($user->exists);
$this->assertNotEmpty($user->email);
}
// Create multiple
$users = factory('User')->times(10)->create();
Testing APIs
class ApiTest extends TestCase {
public function testGetPosts()
{
$response = $this->call('GET', '/api/posts');
$this->assertResponseOk();
$this->assertEquals('application/json', $response->headers->get('Content-Type'));
$data = json_decode($response->getContent());
$this->assertInternalType('array', $data);
}
public function testCreatePostViaApi()
{
$user = factory('User')->create();
$token = $user->generateToken();
$response = $this->call('POST', '/api/posts', [
'title' => 'API Post',
'body' => 'Created via API'
], [], [], [
'HTTP_Authorization' => 'Bearer ' . $token
]);
$this->assertEquals(201, $response->getStatusCode());
$data = json_decode($response->getContent());
$this->assertEquals('API Post', $data->title);
}
}
Mocking
Mocking External Services
public function testEmailNotificationSent()
{
// Mock the Mail facade
Mail::shouldReceive('send')
->once()
->with('emails.welcome', \Mockery::type('array'), \Mockery::type('Closure'));
$user = User::create([
'name' => 'Test',
'email' => 'test@example.com',
'password' => 'secret'
]);
// This should trigger the email
Event::fire('user.registered', [$user]);
}
Mocking Repositories
public function testControllerUsesRepository()
{
$mock = Mockery::mock('PostRepositoryInterface');
$mock->shouldReceive('all')
->once()
->andReturn(new Collection([
new Post(['title' => 'Post 1']),
new Post(['title' => 'Post 2'])
]));
App::instance('PostRepositoryInterface', $mock);
$response = $this->call('GET', '/posts');
$this->assertResponseOk();
}
Test-Driven Development (TDD)
Write tests BEFORE writing code:
Example: Building a Blog Comment System
Step 1: Write the test
public function testUserCanCommentOnPost()
{
$user = factory('User')->create();
$post = factory('Post')->create();
$this->be($user);
$response = $this->call('POST', "/posts/{$post->id}/comments", [
'body' => 'Great post!'
]);
$this->assertRedirectedTo("/posts/{$post->id}");
$this->assertEquals(1, $post->comments()->count());
$comment = $post->comments()->first();
$this->assertEquals('Great post!', $comment->body);
$this->assertEquals($user->id, $comment->user_id);
}
Step 2: Run test (it will fail)
phpunit --filter testUserCanCommentOnPost
Step 3: Write minimum code to make it pass
// routes.php
Route::post('posts/{post}/comments', 'CommentController@store');
// CommentController.php
public function store($postId)
{
$post = Post::findOrFail($postId);
$comment = $post->comments()->create([
'user_id' => Auth::id(),
'body' => Input::get('body')
]);
return Redirect::to("/posts/{$post->id}")
->with('success', 'Comment added!');
}
Step 4: Run test again (should pass)
Step 5: Refactor if needed
Code Coverage
Generate code coverage reports:
phpunit --coverage-html coverage/
Open coverage/index.html in your browser to see which code is tested.
Aim for:
- 80%+ coverage for critical business logic
- 100% coverage for payment/security code
- Don't obsess over 100% everywhere
Continuous Integration
Travis CI Configuration
Create .travis.yml:
language: php
php:
- 5.4
- 5.5
- 5.6
before_script:
- composer install --dev
- php artisan migrate --env=testing
script:
- phpunit
notifications:
email:
recipients:
- team@example.com
on_success: change
on_failure: always
Best Practices from ZIRA Software
- Test behavior, not implementation - Test what the code does, not how it does it
- One assertion per test - Makes failures easier to diagnose
- Descriptive test names -
testUserCanPublishPostnottestPost1 - Fast tests - Keep unit tests under 100ms, integration tests under 1s
- Independent tests - Tests shouldn't depend on each other
- Test edge cases - Empty inputs, null values, maximum lengths
- Don't test framework code - Trust Laravel's tests, test YOUR code
Testing Checklist
For every feature:
- [ ] Model relationships work correctly
- [ ] Validation rules work as expected
- [ ] Routes are accessible/protected correctly
- [ ] Controllers return correct responses
- [ ] Database queries return expected results
- [ ] Events/listeners fire correctly
- [ ] Edge cases are handled
Conclusion
Testing isn't about achieving 100% coverage—it's about building confidence in your code. At ZIRA Software, projects with comprehensive tests deploy faster, have fewer bugs, and are easier to maintain. The time invested in writing tests is repaid many times over.
Start testing today. Your future self (and your team) will thank you.
Need help implementing testing in your Laravel application? Contact ZIRA Software to discuss test-driven development, continuous integration, and building reliable software.