Reputation: 317
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 TransportException
s are handled correctly. For that I need the mailer to throw TransportException
s 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
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
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
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