4

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?

thorndeux
  • 307
  • 2
  • 9
  • 1
    Have you tried adding `class: App\Tests\MailerExceptionTester` to the service definition in 5? – msg Jun 30 '22 at 20:08
  • 1
    @msg Thanks for your comment. Explicitly wiring the custom class does throw the exception every time, but I only want it during specific tests (else the app would not function). Basically, I need the normal mailer for the app and most tests, and the mock mailer for a few specific tests only. – thorndeux Jun 30 '22 at 20:23
  • I see. Combining solution 5 with 3 might do it: Make the `MailerInterface` public so it can be replaced and set it to your instance. Or maybe we are overcomplicating things and it's enough to use solution 2, but replacing the interface instead of the concrete `Mailer` implementation (you might still need to make it public, though). – msg Jun 30 '22 at 20:36
  • @msg I'm afraid I tried all possible combinations :D. Any solution which simply includes making `MailerInterface` public as in solution 5 results in `Error: Cannot instantiate interface Symfony\Component\Mailer\MailerInterface`. I tried solution 2 with the interface as well (both public and not public): Public leads to the same error as above, not public leads to `The "Symfony\Component\Mailer\MailerInterface" service is private, you cannot replace it`. Time for bed... – thorndeux Jun 30 '22 at 21:17

3 Answers3

3

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!

Bradley
  • 369
  • 5
  • 11
  • That's great! It seems that I was missing the arguments: '@mailer.default_transport'. I ended up using a unit test instead (no container) and mocking all the dependencies that would be injected by the container, but your solution solves the original question and is a lot cleaner. I will test it on Monday. – thorndeux Aug 11 '22 at 18:11
1

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();
Leroy
  • 1,600
  • 13
  • 23
  • 1
    Thanks for your answer. Unfortunately, it does not lead to success. Instantiating the class after setting the mock mailer service makes perfect sense, but unfortunately the outcome is the same. Not using the kernel at all is also a good idea, but again, it does not lead to the desired outcome. I had to create a mock mailer manually (as the Mailer class is final and cannot be mocked via `createMock()`), but no exception is thrown. – thorndeux Jul 07 '22 at 09:12
  • 1
    Since the code accepts a MailerInterface, you can also mock the interface instead. Which in this case is a better solution because you mock the entire thing. – Leroy Jul 07 '22 at 20:21
  • Thanks for your comment. I got it to work with a vanilla TestCase - both using a manual mock and with mocking the MailerInterface. I had previously used a wrong assertion in the test, leading to the unexpected result. However, I have not been able to get it to work in a KernelTestCase or WebTestCase. I suspect that autowiring and mocking don't mix very well, so I might make another attempt with autowiring disabled. – thorndeux Jul 11 '22 at 09:58
1

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:

Yury Tolochko
  • 509
  • 3
  • 6
  • This is great, and saved me a whole lot of mocking trouble. Just put a NullTransport on the mail and then assert using this code :) – Oli May 15 '23 at 14:39