thorndeux
thorndeux

Reputation: 317

How can I mock the Symfony mailer during testing?

I am using the Symfony mailer in a custom class in a Symfony 6 project. I am using autowiring through type hinting in the class's constructor, like so:

    class MyClass {
        public function __construct(private readonly MailerInterface $mailer) {}


        public function sendEmail(): array
        {
            // Email is sent down here
            try {
                $this->mailer->send($email);
            
                return [
                    'success' => true,
                    'message' => 'Email sent',
                ];
            } catch (TransportExceptionInterface $e) {
                return [
                    'success' => false,
                    'message' => 'Error sending email: ' . $e,
                ];
            }
        }
    }

The sendEmail() method is called in a controller and everything works fine.

Now I want to test that TransportExceptions are handled correctly. For that I need the mailer to throw TransportExceptions in my tests. However, that does not work as I had hoped.

Note: I cannot induce an exception by passing an invalid email address, as the sendMail method will only allow valid email addresses.

Things I tried:

1) Use mock Mailer

// boot kernel and get Class from container
$container = self::getContainer();
$myClass = $container->get('App\Model\MyClass');

// create mock mailer service
$mailer = $this->createMock(Mailer::class);
$mailer->method('send')
        ->willThrowException(new TransportException());
$container->set('Symfony\Component\Mailer\Mailer', $mailer);

Turns out I cannot mock the Mailer class, as it is final.

2) Use mock (or stub) MailerInterface

// create mock mailer service
$mailer = $this->createStub(MailerInterface::class);
$mailer->method('send')
        ->willThrowException(new TransportException());
$container->set('Symfony\Component\Mailer\Mailer', $mailer);

No error, but does not throw an exception. It seems the mailer service is not being replaced.

3) Use custom MailerExceptionTester class

// MailerExceptionTester.php
<?php

namespace App\Tests;

use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\RawMessage;

/**
 * Always throws a TransportException
 */
final class MailerExceptionTester implements MailerInterface
{
    public function send(RawMessage $message, Envelope $envelope = null): void
    {
        throw new TransportException();
    }
}

And in the test:

// create mock mailer service
$mailer = new MailerExceptionTester();
$container->set('Symfony\Component\Mailer\Mailer', $mailer);

Same result as in 2)

4) Try to replace the MailerInterface service instead of Mailer

// create mock mailer service
$mailer = $this->createMock(MailerInterface::class);
$mailer->method('send')
        ->willThrowException(new TransportException());
$container->set('Symfony\Component\Mailer\MailerInterface', $mailer);

Error message: Symfony\Component\DependencyInjection\Exception\InvalidArgumentException: The "Symfony\Component\Mailer\MailerInterface" service is private, you cannot replace it.

5) Set MailerInterface to public

// services.yaml
services:
    Symfony\Component\Mailer\MailerInterface:
        public: true

Error: Cannot instantiate interface Symfony\Component\Mailer\MailerInterface

6) Add alias for MailerInterface

// services.yaml
services:
    app.mailer:
        alias: Symfony\Component\Mailer\MailerInterface
        public: true

Error message: Symfony\Component\DependencyInjection\Exception\InvalidArgumentException: The "Symfony\Component\Mailer\MailerInterface" service is private, you cannot replace it.

How can I replace the autowired MailerInterface service in my test?

Upvotes: 4

Views: 2586

Answers (3)

Yury Tolochko
Yury Tolochko

Reputation: 509

For anyone like me who spotted this question and just want to assert email messages in your tests. Instead of mocking MailerInterface its better to use build-in MailerAssertionsTrait can work better for you. It has no option to simulate transport exceptions but the trait is great for mail content tests.

// tests/Controller/MailControllerTest.php
namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class MailControllerTest extends WebTestCase
{
    public function testMailIsSentAndContentIsOk()
    {
        $client = static::createClient();
        $client->request('GET', '/mail/send');
        $this->assertResponseIsSuccessful();

        $this->assertEmailCount(1); // use assertQueuedEmailCount() when using Messenger

        $email = $this->getMailerMessage();

        $this->assertEmailHtmlBodyContains($email, 'Welcome');
        $this->assertEmailTextBodyContains($email, 'Welcome');

        $this->assertNotEmpty($email->getContext()['some_param']);
    }
}

References:

Upvotes: 1

Bradley
Bradley

Reputation: 498

I was trying to do exactly this, and I believe I have found a solution based off of what you have already tried.

In my services.yaml I am redeclaring the mailer.mailer service and setting it as public when in the test environment:

when@test:
    services:
        mailer.mailer:
            class: Symfony\Component\Mailer\Mailer
            public: true
            arguments:
                - '@mailer.default_transport'

This setup should make the Symfony Mailer service behave in the exact same way as before, however because it is now public we can overwrite which class it uses in the container if we need.

I copied the custom Mailer class you wrote...

// MailerExceptionTester.php
<?php

namespace App\Tests;

use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\RawMessage;

/**
 * Always throws a TransportException
 */
final class MailerExceptionTester implements MailerInterface
{
    public function send(RawMessage $message, Envelope $envelope = null): void
    {
        throw new TransportException();
    }
}

...and in my test code I get the test container and replace the mailer.mailer service with an instance of the exception throwing class:

$mailer = new MailerExceptionTester();
static::getContainer()->set('mailer.mailer', $mailer);

Now wherever the Mailer service is injected, the class used will be the custom exception throwing class!

Upvotes: 3

Leroy
Leroy

Reputation: 2137

The order of your first attempt should be correct.

// boot kernel and get Class from container
$container = self::getContainer();
$container->set('Symfony\Component\Mailer\Mailer', $mailer);

// create mock mailer service
$mailer = $this->createMock(Mailer::class);
$mailer->method('send')
        ->willThrowException(new TransportException());

$myClass = $container->get('App\Model\MyClass');

Not tested, but you grabbed the class as an object, so de dependency was already resolved to the service before mocking. This should first replace the service in the container, and then grabs the MyClass from the container.

You can however, also skip the build of the container entirely. By just using PhpUnit.

$mock = $this->createMock(Mailer::class);
// ... 

$myClass = new MyClass($mock);
$myClass->sendEmail();

Upvotes: 1

Related Questions