In the process of studying and writing tests, the most difficult topic for me was the issue of creating fake objects, and therefore I often bypassed this process and rarely used all the features. But today, when I have a clear understanding of this topic, I decided to write a short article on creating Mock objects.
What are the Mocking?
In the process of testing our application, the code can interact with various classes and components of the system. And there are times when you don't want a class to be called during test execution. For example, because it parses some heavy file or sends a Webhook. So you can replace them with Mocking objects. And since we no longer interact with the original classes in the process of running tests, we get enough freedom in passing some parameters and returned data.
What are mock objects for?
So, we have roughly decided what Mock objects are. Now let's try to answer the question why we need them.
I see several important reasons for using them.
1. You have no way to use the original class. There are times when a class can send email or sms to users, send a complex API request, and we don't want tests to perform these operations.
For example, in our project there is a functionality for finding addresses that accesses the sending API to Dadata. This API is not free, each request costs money. So sending real requests in tests is a pretty bad idea. In this case, it's enough to simply create a fake dadata interaction class and tell it what data it should return to us without actually sending it to the service via API.
2. Isolated testing. The best approach to testing is to run your code in isolation. If you don't want to add too many dependencies to your testing process and keep it isolated as possible, you can't do it without mock objects.
An example of working with a Mock object. Testing payment gateway.
Below is an example of working with fake objects. I have created a simple application that makes a payment.
Let's imagine that we have a special payment gateway in the form of the following class:
<?php
namespace App\Services;
use App\Models\Payment;
class PaymentGatewayService implements PaymentGatewayServiceInterface
{
public function doPayment(string $token, \App\Models\User $user): int
{
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
$payment = Payment::create([
'amount' => $user->balance,
'currency' => 'EUR',
'source' => $token,
'name' => 'Balance from ' . $user->name,
'email' => $user->email
]);
return $payment->id;
}
}
As you can see, the payment class uses an external dependency and in addition to creating a payment, it communicate with Stripe. And in our tests, we wouldn't want to make unnecessary API requests.
In our controller, we simply accept this service as a dependency:
public function store(Request $request, PaymentGatewayService $paymentGatewayService)
{
$payment_id = $paymentGatewayService->doPayment($request->input('token'), Auth::user());
return redirect(route('leads.create'))->with(['message' => 'Payment created with id: ' . $payment_id]);
}
As an example, I added a simple service call here, but definitely in real projects, in addition to accepting a payment, additional business logic is added. Therefore, let's imagine that in our test we do not want to make external calls, but simply expect to receive the id of our payment. Therefore, we are left with creating a fake PaymentServiceGateway class.
Let's do that:
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\PaymentGatewayService;
use Tests\TestCase;
class PaymentGatewayServiceTest extends TestCase
{
public const TEST_TOKEN = 'test_token';
public function testPaymentCreatedSuccessfully(): void
{
$this->withoutExceptionHandling();
$paymentService = $this->mock(PaymentGatewayService::class);
$paymentService->shouldReceive('doPayment')->with(self::TEST_TOKEN, \Mockery::type(User::class))->andReturn('payment-id');
$response = $this->post(route('payment.store'), [
'amount' => 2000,
'currency' => 'EUR',
'token' => self::TEST_TOKEN
]);
$response->assertRedirect('/leads');
}
}
So, we call $this->mock() method, into which we pass our class, a copy of which is to be created. Next, we tell PHPUnit that we want to send data with a test token and User class specifically for the doPayment method. And it should return the payment id to us.
Before executing the doPayment method of our service, the mocking method prepares a copy of the object of the PaymentServiceGateway class and replaces it with the original wherever it appears during execution.
If there are several methods in your class, for example, one accepts a payment, and the other saves it to the database, and you want to Mock only the payment acceptance method, and leave the record in the database in the original execution, you can use partial mocking:
$this->partialMock(PaymentGatewayService::class, function (MockInterface $mock) {
$mock->shouldReceive('doPayment')->once();
});
In this case, we will only mock the doPayment method of the PaymentGatewayService class, and for other methods it will still use the source code of the method.
Testing the JSON response
In case your original class returns a JSON response, you can test this behavior very easily through fake classes:
$this->mock(PaymentServiceGateway::class, function (MockInterface $mock) {
$mock->shouldReceive('doPayment')
->once()
->andReturn(json_decode(json_encode([
'amount' => 5000,
'key' => 'test_key',
])));
});
$this->post('/payment')
->assertJsonFragment(['amount' => 5000]);
It's worth noting that you can't Mock objects of a class that is final. In this case, you need to work with interfaces:
$this->mock(PaymentGatewayServiceInterface::class, function (MockInterface $mock) {
$mock->shouldReceive('doPayment')->once();
});
And controller will looks like:
public function store(Request $request, PaymentGatewayServiceInterface $paymentGatewayService)
{
$payment_id = $paymentGatewayService->doPayment($request->input('token'), Auth::user());
return redirect(route('leads.create'))->with(['message' => 'Payment created with id: ' . $payment_id]);
}
But do not forget register it in ServiceProvider. Laravel should know, with which class it should map your interface.
For me, this is the most preferred way of working, because I most often designate classes as final so as not to create a large inheritance chain.
An important point of this testing phase is that we have not only created a separate class for accepting payment, we have minimized the connection between different components of the framework, made the code more readable and easier to refactor.