mushulmao
mushulmao

Reputation: 31

Testing with PEST

I am a complete novice in testing and I wanted to know if someone could guide me on what I'm doing wrong. I want to test the following function in my php laravel project:

public static function getFlatSupplierAddress($supplier_address_id): string
    {
        $supplierAddress = Address::findOrFail($supplier_address_id);
        return $supplierAddress->street . " " .
            $supplierAddress->number . " " .
            $supplierAddress->apartment . ", " .
            ($supplierAddress->city ? $supplierAddress->city->name : '') . ", " .
            ($supplierAddress->province ? $supplierAddress->province->name : '') . ", " .
            ($supplierAddress->country ? $supplierAddress->country->name : '');
    }


This is my test code:

it('returns the formatted supplier address', function () {

        $addressMock = Mockery::mock(Address::class);

        $addressMock->shouldReceive('findOrFail')
            ->with(1)
            ->andReturnSelf(); // Retorna el mismo mock

        $addressMock->shouldReceive('getAttribute')
            ->with('street')
            ->andReturn('Calle');
        $addressMock->shouldReceive('getAttribute')
            ->with('number')
            ->andReturn('456');
        $addressMock->shouldReceive('getAttribute')
            ->with('apartment')
            ->andReturn('Apt 789');
        $addressMock->shouldReceive('city')
            ->andReturn((object)['name' => 'Retiro']);
        $addressMock->shouldReceive('province')
            ->andReturn((object)['name' => 'Buenos Aires']);
        $addressMock->shouldReceive('country')
            ->andReturn((object)['name' => 'Argentina']);

        $expectedAddress = 'Calle 456 , Retiro, Buenos Aires, Argentina';

        $formattedAddress = AddressService::getFlatSupplierAddress(1);

        // Assert
        expect($formattedAddress)->toBe($expectedAddress);

Of course I have the problem that I am not using well the Mock since the function returns me a string of an address that it found in the database and it is comparing it with the Mock that I have created.

Upvotes: 1

Views: 1151

Answers (1)

IGP
IGP

Reputation: 15869

You don't need to mock anything in order to test that

it('returns the formatted supplier address', function () {
    // Arrange
    $address = Address::factory()
      ->for(City::factory())
      ->for(Province::factory())
      ->for(Country::factory())
      ->create();

    // Act
    $formatted = getFlatSupplierAddress($address->id);

    // Assert
    $expected = "{$address->street} {$address->number} {$address->apartment} {$address->city?->name}, {$address->province?->name}, {$address->country?->name}";

    $this->assertEquals($formatted, $expected);
    // or expect($formatted)->toBe($expected);
});

Edit a somewhat more in depth explanation


As I commented, findOrFail is not actually a method found in Address. Laravel does this a lot. It forwards calls to other classes under the hood by using __call or __callStatic.

When you do Address::findOrFail(1), it actually calls the underlying Illuminate\Database\Eloquent\Model's static __callStatic('findOrFail', [1]).

... which in turn just does (new static)->findOrFail(1)

... which in turns calls the Model's __call('findOrFail' [1])

... which in turn eventually calls the Model's newQuery() and returns an Illuminate\Database\Eloquent\Builder

... which finally calls findOrFail(1).


And that just covers findOrFail. Whenever you call a Model 's attributes ($address->street, $address->city->name, etc), it also forwards the call to other methods via __call.

If you really wanted to write this test with mocks and make sure it didn't touch the database, I suppose you could write something like this:

use App\Models\{Address, Country, City, Province};
use Illuminate\Database\Eloquent\Builder;

it('returns the formatted supplier address', function () {
    // Arrange
    $address = Address::factory()->make();
    $address->setRelation('city', City::factory()->make());         // Have to set the relations like this
    $address->setRelation('province', Province::factory()->make()); // because the Factory's for method will persist them 
    $address->setRelation('country', Country::factory()->make());   // into the database otherwise
    $address->id = 'fake-id'; // value doesn't really matter.

    // Mock
    $builderMock = Mockery::mock(Builder::class);
    $builderMock->shouldReceive('findOrFail')->with($address->id)->andReturn($address);
    $addressMock = Mockery::mock(Address::class)->makePartial(); // we still need the class's original behavior for some methods.
    $addressMock->shouldReceive('newQuery')->andReturn($builderMock);

    // Act
    $formatted = getFlatSupplierAddress($address->id);

    // Assert
    $expected = "{$address->street} {$address->number} {$address->apartment} {$address->city?->name}, {$address->province?->name}, {$address->country?->name}";

    $this->assertEquals($formatted, $expected);
    // or expect($formatted)->toBe($expected);
});

As an aside, if you want to test every combiantion of having or not having a City, Province and Country in your Address, that's 8 separate cases. You could take advantage of bitwise operations to avoid having to write the same test 8 times.

it('returns the formatted supplier address', function ($i) {
    $address = Address::factory()->create();
    if ($i & 0b001) $address->setRelation('city', City::factory()->make());
    if ($i & 0b010) $address->setRelation('province', Province::factory()->make()); 
    if ($i & 0b100) $address->setRelation('country', Country::factory()->make());
})->with(range(0b000, 0b111))

Or, if you don't care about writing to the testing database

it('returns the formatted supplier address', function ($i) {
    $factory = Address::factory();
    if ($i & 0b001) $factory = $factory->for(City::class);
    if ($i & 0b010) $factory = $factory->for(Province::class); 
    if ($i & 0b100) $factory = $factory->for(Country::class);
    $address = $factory->create();
})->with(range(0b000, 0b111))

Upvotes: 0

Related Questions