2
votes

I have an API written using Symfony2 that I'm trying to write post hoc tests for. One of the endpoints uses an email service to send a password reset email to the user. I'd like to mock out this service so that I can check that the right information is sent to the service, and also prevent an email from actually being sent.

Here's the route I'm trying to test:

/**
 * @Route("/me/password/resets")
 * @Method({"POST"})
 */
public function requestResetAction(Request $request)
{
    $userRepository = $this->get('app.repository.user_repository');
    $userPasswordResetRepository = $this->get('app.repository.user_password_reset_repository');
    $emailService = $this->get('app.service.email_service');
    $authenticationLimitsService = $this->get('app.service.authentication_limits_service');
    $now = new \DateTime();
    $requestParams = $this->getRequestParams($request);
    if (empty($requestParams->username)) {
        throw new BadRequestHttpException("username parameter is missing");
    }
    $user = $userRepository->findOneByUsername($requestParams->username);
    if ($user) {
        if ($authenticationLimitsService->isUserBanned($user, $now)) {
            throw new BadRequestHttpException("User temporarily banned because of repeated authentication failures");
        }
        $userPasswordResetRepository->deleteAllForUser($user);
        $reset = $userPasswordResetRepository->createForUser($user);
        $userPasswordResetRepository->saveUserPasswordReset($reset);
        $authenticationLimitsService->logUserAction($user, UserAuthenticationLog::ACTION_PASSWORD_RESET, $now);
        $emailService->sendPasswordResetEmail($user, $reset);
    }
    // We return 201 Created for every request so that we don't accidently
    // leak the existence of usernames
    return $this->jsonResponse("Created", $code=201);
}

I then have an ApiTestCase class that extends the Symfony WebTestCase to provide helper methods. This class contains a setup method that tries to mock the email service:

class ApiTestCase extends WebTestCase {

    public function setup() {
        $this->client = static::createClient(array(
            'environment' => 'test'
        ));
        $mockEmailService = $this->getMockBuilder(EmailService::class)
            ->disableOriginalConstructor()
            ->getMock();
        $this->mockEmailService = $mockEmailService;
    }

And then in my actual test cases I'm trying to do something like this:

class CreatePasswordResetTest extends ApiTestCase {

    public function testSendsEmail() {
        $this->mockEmailService->expects($this->once())
             ->method('sendPasswordResetEmail');
        $this->post(
            "/me/password/resets",
            array(),
            array("username" => $this->user->getUsername())
        );
    }

}

So now the trick is to get the controller to use the mocked version of the email service. I have read about several different ways to achieve this, so far I've not had much luck.

Method 1: Use container->set()

See How to mock Symfony 2 service in a functional test?

In the setup() method tell the container what it should return when it's asked for the email service:

static::$kernel->getContainer()->set('app.service.email_service', $this->mockEmailService);
# or
$this->client->getContainer()->set('app.service.email_service', $this->mockEmailService);

This does not effect the controller at all. It still calls the original service. Some write ups I've seen mention that the mocked service is 'reset' after a single call. I'm not even seeing my first call mocked out so I'm not certain this issue is affecting me yet.

Is there another container I should be calling set on?

Or am I mocking out the service too late?

Method 2: AppTestKernel

See: http://blog.lyrixx.info/2013/04/12/symfony2-how-to-mock-services-during-functional-tests.html See: Symfony2 phpunit functional test custom user authentication fails after redirect (session related)

This one pulls me out of my depth when it comes to PHP and Symfony2 stuff (I'm not really a PHP dev).

The goal seems to be to change some kind of foundation class of the website to allow my mock service to be injected very early in the request.

I have a new AppTestKernel:

<?php
// app/AppTestKernel.php
require_once __DIR__.'/AppKernel.php';
class AppTestKernel extends AppKernel
{
    private $kernelModifier = null;

    public function boot()
    {
        parent::boot();
        if ($kernelModifier = $this->kernelModifier) {
            $kernelModifier($this);
            $this->kernelModifier = null;
        };
    }

    public function setKernelModifier(\Closure $kernelModifier)
    {
        $this->kernelModifier = $kernelModifier;

        // We force the kernel to shutdown to be sure the next request will boot it
        $this->shutdown();
    }
}

And a new method in my ApiTestCase:

// https://stackoverflow.com/a/19705215
    protected static function getKernelClass(){
        $dir = isset($_SERVER['KERNEL_DIR']) ? $_SERVER['KERNEL_DIR'] : static::getPhpUnitXmlDir();
        $finder = new Finder();
        $finder->name('*TestKernel.php')->depth(0)->in($dir);
        $results = iterator_to_array($finder);
        if (!count($results)) {
            throw new \RuntimeException('Either set KERNEL_DIR in your phpunit.xml according to http://symfony.com/doc/current/book/testing.html#your-first-functional-test or override the WebTestCase::createKernel() method.');
        }
        $file = current($results);
        $class = $file->getBasename('.php');
        require_once $file;
        return $class;
    }

Then I alter my setup() to use the kernel modifier:

public function setup() {
        ...
    $mockEmailService = $this->getMockBuilder(EmailService::class)
        ->disableOriginalConstructor()
        ->getMock();
    static::$kernel->setKernelModifier(function($kernel) use ($mockEmailService) {
        $kernel->getContainer()->set('app.service.email_service', $mockEmailService);
    });
    $this->mockEmailService = $mockEmailService;
}

This works! However I now can't access the container in my other tests when I'm trying to do something like this:

$c = $this->client->getKernel()->getContainer();
$repo = $c->get('app.repository.user_password_reset_repository');
$resets = $repo->findByUser($user);

The getContainer() method returns null.

Should I be using the container differently?

Do I need to inject the container into the new kernel? It extends the original kernel so I don't really know why/how it's any different when it comes to the container stuff.

Method 3: Replace the service in config_test.yml

See: Symfony/PHPUnit mock services

This method requires that I write a new service class that overrides the email service. Writing a fixed mock class like this seems less useful than a regular dynamic mock. How can I test that certain methods have been called with certain parameters?

Method 4: Setup everything inside the test

Going on @Matteo's suggestion I wrote a test that did this:

public function testSendsEmail() {
    $mockEmailService = $this->getMockBuilder(EmailService::class)
        ->disableOriginalConstructor()
        ->getMock();
    $mockEmailService->expects($this->once())
         ->method('sendPasswordResetEmail');
    static::$kernel->getContainer()->set('app.service.email_service', $mockEmailService);
    $this->client->getContainer()->set('app.service.email_service', $mockEmailService);
    $this->post(
        "/me/password/resets",
        array(),
        array("username" => $this->user->getUsername())
    );
}

This test fails because the expected method sendPasswordResetEmail wasn't called:

There was 1 failure:

1) Tests\Integration\Api\MePassword\CreatePasswordResetTest::testSendsEmail
Expectation failed for method name is equal to <string:sendPasswordResetEmail> when invoked 1 time(s).
Method was expected to be called 1 times, actually called 0 times.
1
I have used (in extreme case) the First method BUT not in the setup method but in the test method. Can you give a try?Matteo
Hey Matteo, thanks for the suggestion. I gave it a go but the test failed. I've added my test code to the question so you can check I did what you expected. Thanks!WilliamMayor
I know it pulls you out of your depth but this is the only approach that has worked for me: symfony.com/doc/current/email/testing.html I'd suggest trying the actual code in the example first and try to apply it to your test. It can be tricky. Unit tests are much easier.Cerad
Thanks Cerad! That looks interesting! Does it stop swiftmailer from sending the email? Not that that's too bad a thing I suppose.WilliamMayor
Take some time and read through the entire email section carefully. Especially: symfony.com/doc/current/email/dev_environment.html Plenty of techniques to suppress the sending of emails or to send the email only to yourself during development.Cerad

1 Answers

0
votes

Thanks to Cered's advice I've managed to get something working that can test that the emails I expect to be sent actually are. I haven't been able to actually get the mocking to work so I'm a bit reluctant to mark this as "the" answer.

Here's a test that checks that an email is sent:

public function testSendsEmail() {
    $this->client->enableProfiler();
    $this->post(
        "/me/password/resets",
        array(),
        array("username" => $this->user->getUsername())
    );
    $mailCollector = $this->client->getProfile()->getCollector('swiftmailer');
    $this->assertEquals(1, $mailCollector->getMessageCount());
    $collectedMessages = $mailCollector->getMessages();
    $message = $collectedMessages[0];

    $this->assertInstanceOf('Swift_Message', $message);
    $this->assertEquals('Reset your password', $message->getSubject());
    $this->assertEquals('[email protected]', key($message->getFrom()));
    $this->assertEquals($this->user->getEmail(), key($message->getTo()));
    $this->assertContains(
        'This link is valid for 24 hours only.',
        $message->getBody()
    );
    $resets = $this->getResets($this->user);
    $this->assertContains(
        $resets[0]->getToken(),
        $message->getBody()
    );
}

It works by enabling the Symfony profiler and inspecting the swiftmailer service. It's documented here: http://symfony.com/doc/current/email/testing.html