phpsymfonyphpunitsymfony6symfony-mailer

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?


Solution

  • 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!