Background
I have a system with a microservices setup. A few of these microservices run a laravel installation. In order to share some key models, a repo was shared using git/packagist.
Here is a diagram:
Microservice A
Microservice B
- ...
These both share Library C
. This library has the shared models. This is outside of a normal laravel installation, but the composer includes "laravel/framework": "^9.0"
.
Note: There good external reasons to share the functionality - the microservices have come out of a monolith and are still developing fluidly and are not mature enough for a complete decoupling. This will come in time.
I wish to unit test these models.
Specifics
The requirement is that several models (User
, Customer
.. etc) all require addresses. Normalising these out would introduce complexity elsewhere that is not appropriate yet, so a trait is good for now. These have UK postcodes that require a specific validation against a database. Postcodes are modelled using a Postcode
model.
I created a trait : AddressTrait
. This offers some useful functionality. Included in this is a Postcode validation. This intercepts a set request in laravel (eg: $user->postcode = 'AB10 1AB
)
/**
* Automatically updates the log/lat from the postcode
* @param $value
*/
public function setPostcodeAttribute($value): void
{
// update postcode
$this->attributes['postcode'] = strtoupper($value);
// now update lat/long
$postcode = Postcode::where('pcd', '=', str_replace(' ', '', $value))
->orWhere('pcd', '=', $value)
->first();
if ($postcode) {
$this->attributes['latitude'] = $postcode->latitude;
$this->attributes['longitude'] = $postcode->longitude;
}
}
This works as expected. Note - it is to be extended quite a bit further with much more complexity, but this is step 1 and completely represents the problem.
Testing
If I interact with the postcode attribute, such as $user->postcode = 'AB10 1AB
, this attempts to load the Postcode
from the database, and the following error occurs:
Error : Call to a member function connection() on null
^ This is expected.
I would like to unit test this: ie. no reaching out the class and mocking system/functional elements. Thus, I need to mock the Postcode
load (Postcode::where(..) ..
).
As this is a static call, I have used mockery ("mockery/mockery": "dev-master"
).
Here is the current attempt:
// ...
use Mockery;
use PHPUnit\Framework\TestCase;
// ...
public function testPostcodeProcessing(): void
{
$postcode_value = 'AB10 1AB';
$postcode_content = [
'pcd' => $postcode_value,
'latitude' => '0.1',
'longitude' => '0.2'
];
$mock_postcode = Mockery::mock(Postcode::class);
$mock_postcode->shouldReceive('where')->once()->andReturn($mock_postcode);
$mock_postcode->shouldReceive('orWhere')->once()->andReturn($mock_postcode);
$mock_postcode->shouldReceive('first')->once()->andReturn($postcode_content);
$model = $this->createTraitImplementedClass();
$model->postcode = $postcode_value;
}
protected function createTraitImplementedClass(): Model
{
return new class extends Model {
use AddressTrait;
};
}
TLDR question
I would like to unit test this function: ie. no reaching out the class and mocking.
- How do I mock a laravel/eloquent static call, given that:
- this is to be tested outside laravel
- there is no database connection
OR
- How do I refactor this to allow it to be more testable
Super TLDR;
How do I mock the load in:
public function tldr(): void
{
// this eloquent lookup needs to be mocked (not moved, refactored etc etc..)
$postcode = Postcode::where('pcd', '=', 'AB10 1AB')->first();
}
Notes:
- These are unit tests
- I would prefer to do this "the laravel way", but given the unusual circumstances things such as
mockery
might make sense - May be a gotcha: I am using the phpunit
PHPUnit\Framework\TestCase
- not the usual PHP test case. This is not a "requirement", but I imagined a mock shouldn't need the extended features.
Any help with this would be appreciated!
CodePudding user response:
You can make your method more test friendly
- Injectable external class to remove hidden dependencies
- Keep the formatting/input validation outside if it is not related to "something" structural
- Separate functionalities or the S in SOLID principles (move the lookup for Postcode instance to where it belongs)
like this
/**
* Automatically updates the log/lat from the postcode
* @param string $value
* @param Postcode $postcode
*/
public function setPostcodeAttribute($value, Postcode $postcode = null): void
{
// update postcode
$this->attributes['postcode'] = $value;
if ($postcode) {
$this->attributes['latitude'] = $postcode->latitude;
$this->attributes['longitude'] = $postcode->longitude;
}
}
CodePudding user response:
What if you abstracted away the part where you get the postcode?
public function setPostcodeAttribute($value): void
{
// update postcode
$this->attributes['postcode'] = strtoupper($value);
// now update lat/long
$postcode = $this->getPostCode($value);
if ($postcode) {
$this->attributes['latitude'] = $postcode->latitude;
$this->attributes['longitude'] = $postcode->longitude;
}
}
// you could make this method protected as well
// but if you do, your need to call the shouldAllowMockingProtectedMethods()
// when creating your mock
public function getPostCode(string $value): ?Postcode
{
return Postcode::where('pcd', '=', str_replace(' ', '', $value))
->orWhere('pcd', '=', $value)
->first();
}
If you do it like this, you no longer need to mock Eloquent Query builder at all. Partially mocking a class that uses that Address trait should give you what you need. I'm not sure if this works for anonymous classes though
public function test_existing_postcode()
{
// Arrange
$userMock = Mockery::mock(User::class)->makePartial();
$user = new User;
$postcode_value = 'AB10 1AB';
$postcode = new PostCode([
'pcd' => $postcode_value,
'latitude' => '0.1',
'longitude' => '0.2'
]);
// Expect
$userMock->expects()
->getPostCode($postcode_value)
->andReturn($postcode);
// Act
$user->postcode = $postcode_value;
// Assert
$this->assertEquals($user->latitude, $postcode->latitude);
$this->assertEquals($user->longitude, $postcode->longitude);
}
public function test_nonexisting_postcode()
{
// Arrange
$userMock = Mockery::mock(User::class)->makePartial();
$user = new User;
$postcode_value = 'AB10 1AB';
// Expect
$userMock->expects()
->getPostCode($postcode_value)
->andReturn(null);
// Act
$user->postcode = $postcode_value;
// Assert
$this->assertNull($user->latitude);
$this->assertNull($user->longitude);
}
Although I wouldn't recommend it, if you had a static method inside the Postcode model.
class Postcode extends Model
{
public static function getPostcodeByValue(string $value): ?Postcode
{
return Postcode::...
}
}
You could mock it with
$postcodeMock = \Mockery::mock('alias:Postcode');
$postcodeMock->shouldReceive('getPostcodeByValue')
->with($value)
->andReturn($postcode);
I'm not sure if expects()
works, but if it does, you can also write this as
$postcodeMock = \Mockery::mock('alias:Postcode');
$postcodeMock->expects()
->getPostcodeByValue($value)
->andReturn($postcode);
Important: for this to work, the Postcode
class should not have been loaded (by this or any previous tests). It's that fragile.
CodePudding user response:
After some extensive looking into this, I've found the answer using mockery aliases. This is done as follows:
Isolate this class/test from the remainder of the tests
If you create an alias, this overwrites the class globally for the rest of the current process. It's risky, but this can be done and many of the problems sidestepped by running the test/class in a separate process.
This can be done using the docblock:
/**
* At a class level
* @runTestsInSeparateProcesses
* @preserveGlobalState disabled
*/
Mock the class as an alias
Aliases mock static classes. This is the key point I was missing during my question - I missed the alias:
part.
public function testPostcodeProcessing(): void
{
// define this first to intercept the global instantiation
$mock_postcode = Mockery::mock('alias:' . Postcode::class);
// ...
}
The above mock will override ALL Postcode classes in this test/test class. Thus, it should be declared first.
Add your responses and assertions
This is entirely up to you, but here is the example and assertions I created.
/*
* Tests that the postcode processes correctly.
*/
public function testPostcodeProcessing(): void
{
// define this first to intercept the global instantiation
$mock_postcode = Mockery::mock('alias:' . Postcode::class);
// set up a returned class
$returned_postcode = new Postcode();
$postcode_pcd = 'AB10 1AB';
$postcode_latitude = 0.1;
$postcode_longitude = 0.2;
$returned_postcode->pcd = $postcode_pcd;
$returned_postcode->latitude = $postcode_latitude;
$returned_postcode->longitude = $postcode_longitude;
// Set up the mock
$mock_postcode->shouldReceive('where')->once()->andReturn($mock_postcode);
$mock_postcode->shouldReceive('orWhere')->once()->andReturn($mock_postcode);
$mock_postcode->shouldReceive('first')->once()->andReturn($returned_postcode);
$model = $this->createTraitImplementedClass();
$model->postcode = $postcode_pcd;
$this->assertEquals($postcode_pcd, $model->postcode, 'The postcode object pcd was not set');
$this->assertEquals($postcode_latitude, $model->latitude, 'The postcode object latitude was not loaded');
$this->assertEquals($postcode_longitude, $model->longitude, 'The postcode object longitude was not loaded');
}
Note - these are "step 1" tests. The real class is more complex, and the test will be more complex. However, this gives the core solution to the instantiation issue.
TLDR;
- Run this in a separate process
- Use an Alias (and remember to declare it as an alias -
alias:SomeClass
)