12 Keys to Writing Unit Tests You Can Be Proud Of
Herminio Heredia
1 month ago
Have you ever found yourself looking at your unit tests with a disapproving grimace?
That feeling that something’s just not right—that the code is a bit “ugly” or maybe you’re not getting the most out of your tests. Relax, you’re not alone!
Writing effective unit tests is a skill that gets refined over time and with practice. But don’t worry—this guide reveals the best practices for writing unit tests in Laravel that you can feel proud of.
Get ready to master the art of unit testing and take your code to a new level of quality and confidence.
Let’s get started!
1. A Single Purpose: Clarity and Focus
Each unit test should have a single objective. This means it should focus on testing one specific aspect of your code—whether it’s a function, method, or class.
Example:
// Incorrect: Tests multiple things in a single test
public function test_user_can_be_created_and_updated()
{
$user = User::factory()->create();
$this->assertDatabaseHas('users', ['id' => $user->id]);
$user->name = 'Jane Doe';
$user->save();
$this->assertDatabaseHas('users', ['name' => 'Jane Doe']);
}
// Correct: Separate into individual tests
public function test_user_can_be_created()
{
$user = User::factory()->create();
$this->assertDatabaseHas('users', ['id' => $user->id]);
}
public function test_user_can_be_updated()
{
$user = User::factory()->create();
$user->name = 'Jane Doe';
$user->save();
$this->assertDatabaseHas('users', ['name' => 'Jane Doe']);
}
Benefits:
- Greater clarity: Tests are easier to understand and maintain.
- Better error isolation: If a test fails, you know exactly which part of the code is failing.
- Easier debugging: Finding the cause of an error is much faster.
2. Descriptive Names: Communicate Clearly
A test’s name should clearly describe what’s being tested. Imagine reading only the test name without seeing the code—could you understand what it’s testing?
Example:
// Incorrect: Generic name
public function test_user()
{
// ...
}
// Correct: Specific name
public function test_user_can_be_created_with_valid_data()
{
// ...
}
Benefits:
- Instant documentation: Test names act as documentation for the code.
- Facilitates navigation: Finding relevant tests is faster.
- Improves team communication: Everyone understands each test’s purpose.
3. AAA Structure: Organized for Readability
The AAA (Arrange, Act, Assert) structure is a common pattern for organizing unit tests.
- Arrange: Prepare the test environment, create the necessary objects, and set up data.
- Act: Execute the code that’s being tested.
- Assert: Verify that the outcome is as expected.
Example:
public function test_user_can_be_created_with_valid_data()
{
// Arrange
$userData = [
'name' => 'John Doe',
'email' => 'john.doe@example.com',
'password' => 'password'
];
// Act
$user = User::create($userData);
// Assert
$this->assertInstanceOf(User::class, $user);
$this->assertDatabaseHas('users', $userData);
}
Benefits:
- Better readability: Tests are easier to follow and understand.
- Consistency: All tests follow the same structure, making them easier to maintain.
- Better organization: Separates each test phase clearly.
4. Total Independence: Avoid Dependencies
Unit tests must be independent of each other. This means that the result of one test should not affect the result of another.
Example:
// Incorrect: Tests depend on the order of execution
public function test_user_can_be_created()
{
$user = User::factory()->create();
// ...
}
public function test_user_count_is_correct()
{
// This test depends on the previous test having run
$this->assertCount(1, User::all());
}
// Correct: Each test creates its own data
public function test_user_can_be_created()
{
$user = User::factory()->create();
// ...
}
public function test_user_count_is_correct()
{
User::factory()->create();
$this->assertCount(1, User::all());
}
Benefits:
- Greater reliability: Tests are more robust and less likely to fail due to external factors.
- Facilitates parallel execution: Tests can run in any order or in parallel.
- Simplifies maintenance: Changes in one test do not affect other tests.
5. Environment Isolation: Full Control
Unit tests should be isolated from external environments like databases, file systems, or external APIs. You can achieve this using mocks, stubs, or in-memory databases.
Example:
// Incorrect: The test depends on an external API
public function test_user_can_be_created_with_external_api_call()
{
// ... code that makes an external API call ...
}
// Correct: Use a mock to simulate the external API
public function test_user_can_be_created_with_external_api_call()
{
// Mock the external API
$mock = Mockery::mock(ExternalApiService::class);
$mock->shouldReceive('createUser')->andReturn(true);
$this->app->instance(ExternalApiService::class, $mock);
// ... code that uses the external API ...
}
Benefits:
- Faster execution speed: Tests run more quickly without relying on external resources.
- Greater reliability: Tests aren’t affected by issues in the external environment.
- Easier error replication: You can simulate different scenarios and responses from external dependencies.
6. Boundary Tests: Don’t Forget the Extremes
Boundary tests focus on testing the limits of your code, such as maximum and minimum values, null, or empty values. These cases are often where errors hide.
Example:
public function test_username_must_be_at_least_3_characters_long()
{
$this->expectException(ValidationException::class);
User::factory()->create(['name' => 'ab']);
}
public function test_username_cannot_be_longer_than_255_characters()
{
$this->expectException(ValidationException::class);
User::factory()->create(['name' => str_repeat('a', 256)]);
}
Benefits:
- Greater code coverage: You test cases that often go unnoticed.
- Early error detection: You find errors that could cause production issues.
- More robust code: The code is more resistant to unexpected inputs.
7. Continuous Refactoring: Keep Your Tests Clean
Unit tests, like production code, should be regularly refactored. Remove duplicated code, improve readability, and keep tests up to date.
Example:
// Incorrect: Duplicated code in multiple tests
public function test_user_can_be_created()
{
$userData = [
'name' => 'John Doe',
'email' => 'john.doe@example.com',
'password' => 'password'
];
// ...
}
public function test_user_can_be_updated()
{
$userData = [
'name' => 'John Doe',
'email' => 'john.doe@example.com',
'password' => 'password'
];
// ...
}
// Correct: Extract duplicated code into a helper method
private function createUserData()
{
return [
'name' => 'John Doe',
'email' => 'john.doe@example.com',
'password' => 'password'
];
}
public function test_user_can_be_created()
{
$userData = $this->createUserData();
// ...
}
public function test_user_can_be_updated()
{
$userData = $this->createUserData();
// ...
}
Benefits:
- Greater maintainability: Tests are easier to modify and update.
- Reduced duplicated code: Improves readability by avoiding repetition.
- Greater efficiency: Saves time by not rewriting the same code multiple times.
8. “Happy” and “Sad” Paths: Cover All Scenarios
Make sure to cover both success (“happy”) cases and error (“sad”) cases. Test what happens when input is valid, invalid, null, or empty, etc.
Example:
// "Happy" test: User is created successfully
public function test_user_can_be_created_with_valid_data()
{
// ...
}
// "Sad" test: An exception is thrown if the email is invalid
public function test_user_cannot_be_created_with_invalid_email()
{
$this->expectException(ValidationException::class);
User::factory()->create(['email' => 'invalid_email']);
}
Benefits:
- Greater confidence in the code: Verifies that the code works correctly in all situations.
- Early error detection: Finds bugs that might go unnoticed in success cases.
- More robust code: The code is more resistant to unexpected inputs.
9. Use Datasets: Reduce Repetition
If you need to test the same logic with different sets of data, use datasets. This allows you to run the same test with multiple data variations without duplicating code.
Example:
/**
* @dataProvider validEmailProvider
*/
public function test_user_can_be_created_with_valid_email($email)
{
$user = User::factory()->create(['email' => $email]);
$this->assertDatabaseHas('users', ['email' => $email]);
}
public function validEmailProvider()
{
return [
['john.doe@example.com'],
['jane.doe+test@example.com'],
['test.user@subdomain.example.com']
];
}
Benefits:
- Reduced duplicated code: Improves readability by avoiding repetition.
- Greater efficiency: Saves time by not writing the same test multiple times.
- Greater code coverage: Tests different scenarios with fewer lines of code.
10. Mocks and Stubs: Control Your Dependencies
Use mocks and stubs to simulate the behavior of external dependencies. This allows you to isolate the code being tested and control the dependencies’ responses.
Example:
public function test_user_can_be_created_with_external_api_call()
{
// Mock the external API
$mock = Mockery::mock(ExternalApiService::class);
$mock->shouldReceive('createUser')->andReturn(true);
$this->app->instance(ExternalApiService::class, $mock);
// ... code that uses the external API ...
}
Benefits:
- Faster execution speed: Tests run more quickly without relying on external resources.
- Greater reliability: Tests aren’t affected by external environment issues.
- Easier error replication: You can simulate different scenarios and responses from external dependencies.
11. Don’t Forget Integration Tests
Unit tests are important, but they’re not enough. You also need integration tests to verify that the different components of your application work correctly together.
Example:
public function test_user_can_login()
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password'
]);
$response->assertRedirect('/home');
$this->assertAuthenticatedAs($user);
}
Benefits:
- Greater confidence in the application: Verifies that the application works as a whole.
- Early detection of integration errors: Finds bugs that might slip by in unit tests.
- Higher software quality: Delivers a more robust and reliable product.
12. Tools for Success: Leverage the Laravel Ecosystem
Laravel provides a set of tools that make unit testing easier. Take advantage of PHPUnit, factories, seeders, and more to write more efficient tests.
Example:
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_be_created()
{
$user = User::factory()->create();
$this->assertDatabaseHas('users', ['id' => $user->id]);
}
}
Benefits:
- Higher productivity: Writing tests is faster and easier.
- Higher quality tests: Leverage best practices and available tools.
- Better integration with Laravel: Your tests integrate seamlessly with the framework.
Conclusion: The Path to Mastery
Writing effective unit tests is an ongoing journey of learning and improvement. Don’t be discouraged if it’s hard at first or if your tests aren’t perfect. What’s important is that you start applying best practices and strive to improve with every test you write.
Over time, you’ll develop the intuition and experience needed to write impeccable unit tests that give you the confidence your code is solid, reliable, and ready to face any challenge.
Keep practicing and don’t give up!