Testing with PHPUnit

itsimiro
6 min readMay 13, 2024

--

Testing is super important in development. Today, let’s talk about the basics of testing using PHPUnit. We’ll cover different types of tests like feature tests, unit tests, mocks, and some tips. For example, let’s take a simple User API with a few endpoints (create, update, get).

Feature tests

Let’s dive into feature tests. In Laravel, feature tests aim to check how well your application functions from a user’s viewpoint. They test how different parts of your app collaborate to achieve certain goals. Usually, I combine feature tests with integration tests where I check the behavior of system components.

  1. HTTP Endpoints: Test the behavior of your application’s routes and controllers by making HTTP requests and asserting the responses.
  2. Authentication and Authorization: Test the authentication and authorization mechanisms in your application. Verify that users can log in and access protected resources and that unauthorized users are redirected or denied access.
  3. External Integrations: If your application interacts with external services or APIs, test these integrations. Mock external dependencies where possible to isolate your tests and prevent them from making actual requests to external services.

Let’s take a simple endpoint as an example:

public function update(UserUpdateRequest $request, User $user): UserResource
{
$this->authorize('update', $user);

$user->update([
'name' => $request->getName()
]);

return UserResource::make($user);
}

And write a feature test for it. What do we need to check in a feature test? First, we’ll verify the case of returning a 422 error when request data validation fails, returning a 403 when Policy validation fails, and then we’ll check the 200 HTTP response when we successfully update the data.

class UpdateTest extends TestCase
{
use WithFaker;

private string $routeName = 'api.users.update';

public function testForbidden(): void
{
// Acting as a user without update permissions
$this->actingAsUser();
$user = User::factory()->create();

$this->postJson(route($this->routeName, $user), [])
->assertForbidden();
}

public function testUnprocessable(): void
{
$this->actingAsAdmin();
$user = User::factory()->create();

// Pass an incorrect e-mail address
$updateData = [
'email' => 'incorrect',
];

$this->postJson(route($this->routeName, $user), $updateData)
->assertUnprocessable();
}

public function testSuccess(): void
{
$this->actingAsAdmin();
$user = User::factory()->create();

$updateData = [
'email' => $this->faker->unique()->safeEmail,
];

$response = $this->postJson(route($this->routeName, $user), $updateData);
$response->assertOk();

$this->assertEquals($updateData['email'], $response->json('data.email'));
}
}

Here, we’ve covered a simple case of updating an email. We’ve ensured that the response contains the resource with the email we provided.

Unit tests

Unit tests in Laravel focus on testing individual units of code, such as classes, methods, or functions, in isolation from the rest of the application.

Here are some key things you should test in unit tests:

  1. Business Logic: This includes testing algorithms, calculations, and any other logic that performs specific tasks or manipulates data.
  2. Services and Repositories: If your application uses service classes or repositories to encapsulate business logic or interact with external resources, test these classes in isolation. Mock any external dependencies to ensure that your tests focus only on the behavior of the class being tested.
  3. Validation Rules: If your application uses custom validation rules, test these rules to ensure they correctly validate input data. Test both valid and invalid inputs to verify that the rules behave as expected.
  4. Event Listeners and Observers: If your application uses event listeners or observers to respond to events triggered by the system, test the behavior of these listeners.
  5. Middleware: If your application uses custom middleware, test the behavior of the middleware classes. Verify that they correctly modify incoming requests or outgoing responses as intended.
  6. Helpers and Utilities: Test any helper functions or utility classes that your application relies on. These may include functions for formatting data, generating unique identifiers, or performing other common tasks.

Let’s take a user observer as an example:

class UserObserver
{
public function __construct(private readonly UserEmailFormatter $userEmailFormatter)
{}

public function creating(User $model): void
{
$model->email = $this->userEmailFormatter->format($model->email);
}
}

In unit tests, we’ll verify that after updating the user, the email is the one we expect.

class UserObserverTest extends TestCase
{
private UserObserver $userObserver;
private MockInterface $userEmailFormatter;

protected function setUp(): void
{
parent::setUp();

$this->userEmailFormatter = Mockery::mock(UserEmailFormatter::class);
$this->app->instance(UserEmailFormatter::class, $this->userEmailFormatter);

$this->userObserver = $this->app->make(UserObserver::class);
}

public function testCreating(): void
{
$user = User::factory()->make();
$newEmail = $this->faker->email;

$this->userEmailFormatter
->shouldReceive('format')
->with($user->email)
->andReturn($newEmail)
->once();

$this->userObserver->creating($user);

$this->assertEquals($newEmail, $user->email);
}
}

Here, we’ve verified that we set the correct email in our model. We simply needed to create a mock for userEmailFormatter and call the creating method from the observer.

Mocks

A mock is a pretend version of a real object used in testing. It helps test code by simulating how real objects would behave, making tests simpler, faster, and more focused.

Using mocks in testing has several benefits:

  1. Isolation: Mocks allow you to isolate the code you are testing from its dependencies. By replacing real objects with mock objects, you can focus your tests on the behavior of the code under test without worrying about the behavior of its dependencies.
  2. Controlled Behavior: Mocks allow you to control the behavior of dependencies during testing. You can specify exactly how you expect a dependency to behave in a particular test scenario, including what methods should be called and with what parameters. This allows you to test different code paths and edge cases more thoroughly.
  3. Speed: Mocks can improve the speed of your tests by removing the need to interact with external systems or resources.
  4. Reduced Complexity: Mocks can simplify the setup and teardown of test environments by removing the need to set up and tear down complex external systems or resources.
  5. Isolation from Changes: Mocks can protect your tests from changes in external dependencies. If the behavior of a dependency changes, you can update the mock object to reflect the new behavior without having to change your tests.

For example, we have a class called UserService that interacts with a database to perform user-related operations, such as fetching user data. We want to test a method in another class that depends on UserService, but we don't want to interact with the real database during testing. Instead, we'll use a mock object to simulate the behavior of UserService.

class UserService
{
public function getUserById(int $userId): User
{
// Simulated database query
// This would typically be a real database query in production
return $user;
}
}

class SomeClass
{
public function __construct(private readonly UserService $userService)
{}

public function getUserDetails(int $userId): string
{
$user = $this->userService->getUserById($userId);
// Perform some operation with the user data
return "Details for {$user->getKey()}";
}
}

Now, let’s write a test for the SomeClass class using PHPUnit with a mock object for UserService:

class SomeClassTest extends TestCase
{
public function testGetUserDetails()
{
// Create a mock object for UserService
$userServiceMock = Mockery::mock(UserService::class);

// Set up the expected behavior of the mock
$userServiceMock->shouldReceive('getUser')
->with(123)
->willReturn(Mockery::mock(User::class));

// Instantiate SomeClass with the mock UserService
$someClass = new SomeClass($userServiceMock);

// Call the method being tested
$result = $someClass->getUserDetails(123);

// Assert the result
$this->assertEquals("Details for User 123", $result);
}
}

Testing tips

Here are some tips to help you write effective tests in Laravel:

  1. Test-Driven Development (TDD): Consider practicing Test-Driven Development (TDD), where you write tests before writing the actual code. TDD can help you design cleaner and more maintainable code, as well as ensure that your code meets the specified requirements.
  2. Database Transactions: Use database transactions to isolate your tests. Laravel’s testing environment automatically wraps each test method in a transaction and rolls back the transaction after the test completes, so you don’t have to worry about cleaning up after each test.
  3. Creating your own TestCase is ok: You can create custom test cases, such as ApiTestCase, for special cases where you define test behavior such as authentication, role creation, permissions, and more.

Conclusion

In summary, testing is super important for making sure our apps work well. With tools like PHPUnit and Laravel’s testing features, we can check different parts of our code to catch problems early. Whether it’s checking how users interact with our app or testing individual parts, testing helps us find and fix bugs. By testing our code, we can make sure our apps are reliable and work as they should.

--

--

itsimiro
itsimiro

Written by itsimiro

Passionate developer exploring the realms of software engineering. Writing about tech, coding adventures, and everything in between!

No responses yet