phpsymfonyphpunitapi-platform.com

Assert that a specific method is called when running API endpoint test


Stack versions:

Writing a test for the DELETE endpoint of the User resource.

When the endpoint is called, the user must be soft deleted and several fields with sensitive data must be set to null or overwritten with gibberish. The user must match some conditions before it can be deleted.

It is easy to check if the endpoint does what is expected by looking at the returned response data or retrieving the user from the database.

I am asked to also check that the method NewsletterService::removeUserFromAlLists() is called. So I created a mock object and replaced the NewsletterService from the test container by the mock.

However, when performing the request, the DeleteUserAction is injected the real NewsletterService. Why?

Failure message:

There was 1 failure:

1) App\Tests\Functional\Entity\UserTest::testErase()
Expectation failed for method name is "removeUserFromAlLists" when invoked 1 time(s).
Method was expected to be called 1 times, actually called 0 times.

Test code:

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Enum\User\UserErasedReasonsEnum;
use App\Entity\User;
use App\Factory\UserFactory;
use App\Service\NewsletterService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Zenstruck\Browser\HttpOptions;
use Zenstruck\Browser\Test\HasBrowser;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

class UserTest extends ApiTestCase
{
    use ResetDatabase;
    use Factories;
    use HasBrowser;

    public function testErase(): void
    {
        static::bootKernel();
        $container = static::getContainer();

        $userToAuthenticate = UserFactory::createOne();
        $user = UserFactory::createOne([]);

        $newsletterServiceMock = $this->createMock(NewsletterService::class);
        $newsletterServiceMock->expects($this->once())
            ->method('removeUserFromAlLists')
            ->with('email', $user->getEmail());
        $container->set(NewsletterService::class, $newsletterServiceMock);

        $erasedDate = (new \DateTime())->setTime(0, 0);
        $erasedReason = (string)UserErasedReasonsEnum::WHATEVER;

        $this->browser()
            ->disableReboot()
            ->actingAs($userToAuthenticate->_real())
            ->delete($this->getIriFromResource($user))
            ->assertSuccessful()
        ;
    }

}

DeleteUserActionCode:

use App\Service\NewsletterService;
use Symfony\Bundle\SecurityBundle\Security;
use PHPUnit\Framework\MockObject\MockObject;

class DeleteAction
{
    public function __construct(
        private readonly Security $security,
        private readonly NewsletterService $newsletterService,
    ) {}

    public function __invoke(Request $request, int $id): Response
    {
        $user = $this->security->getUser();

        $this->newsletterService->removeUserFromAlLists($user->getEmail());
        // dd($this->newsletterService instanceof MockObject ? 'true' : 'false'); 
        // When not commented returns false
        
        // ...
    }
}

Solution

  • The issue is you are setting the mock for the service in a wrong container. The container created by the ApiTestCase class and the container created by the Zenstruck Browser are not the same. You can solve the issue by one of these methods:

    1. Set the mock for the service in the Zenstruck Browser container:

    $this->browser()
        ->use(function () use ($newsletterServiceMock) {
            self::getContainer()->set(NewsletterService::class, $newsletterServiceMock);
        });
    

    Full test code:

    public function testErase(): void
    {
        static::bootKernel();
        $container = static::getContainer();
    
        $userToAuthenticate = UserFactory::createOne();
        $user = UserFactory::createOne([]);
    
        $newsletterServiceMock = $this->createMock(NewsletterService::class);
        $newsletterServiceMock->expects($this->once())
            ->method('removeUserFromAlLists')
            ->with($user->getEmail());
    
        $this->browser()
            ->disableReboot()
            ->use(function () use ($newsletterServiceMock) {
                self::getContainer()->set(NewsletterService::class, $newsletterServiceMock);
            })
            ->actingAs($userToAuthenticate->_real())
            ->delete($this->getIriFromResource($user))
            ->assertSuccessful();
    }
    

    2. You can use the Application Tests functionality provided by the Symfony. In this case you can set the mock for the service directly in the ApiTestCase's container.

    public function testErase(): void
    {
        $client = static::createClient();
        $container = static::getContainer();
    
        $userToAuthenticate = UserFactory::createOne();
        $user = UserFactory::createOne([]);
    
        $newsletterServiceMock = $this->createMock(NewsletterService::class);
        $newsletterServiceMock->expects($this->once())
            ->method('removeUserFromAlLists')
            ->with($user->getEmail());
        $container->set(NewsletterService::class, $newsletterServiceMock);
    
        $response = $client->request('DELETE', '/api/users/' . $user->getId());
    
        $this->assertResponseIsSuccessful();
    }
    

    Note: You also have an error in creating the mock. You are only passing the user's email as a single argument to the removeUserFromAlLists function. So you just need to set the expectation like this: with($user->getEmail())

    $newsletterServiceMock = $this->createMock(NewsletterService::class);
    $newsletterServiceMock->expects($this->once())
        ->method('removeUserFromAlLists')
        ->with($user->getEmail());