This article is not maintained any longer and likely not up to date. DuckDuckGo might help you find an alternative.
Testing mails in Laravel
The case: Test that users can send mails to other users
Imagine a simple social network where users can send emails to other users. We will use Laravel and test driven development to create an app that does exactly this.
An user visits the profile of another user and when he clicks on "Message" he can send a customized email to the other user via the system. He can even give a custom subject.
I know that for such a simple use case we could just use <a href="mailto:name@domain.com"></a>
but I want to show you how to use and test email in Laravel.
The setup
You just need a fresh Laravel app. At the time of writing this article this was v7.8.1, but anything after v5.6 should be 80% matching (please report issues). For a minimal setup, remove all lines starting with DB_
in your .env
, add DB_CONNECTION=sqlite
, create a file called database.sqlite
in the /database
folder and run php artisan migrate
.
The test
We will first right an automated test. This will fail and we will add code step by step to fix the problems to finally get a test that gives us confidence that the code works as intended.
Let's create the test: php artisan make:test ContactUserTest
. I will show you how my initial test looks like, but please be aware that I spent around 10 minute into thinking "How should this work?". Writing the test is when you define how you wish your app should work. We are first testing the expected action (it_does_send_a_mail_from_one_user_to_another
). This is called the happy path. We will later check that unexpected behavior does not happen – the sad path.
I'll explain the details after the code, but I left some problems in there so you can see how test driven development (TDD) works. Write a failing test and fix one problem after the other until you get a passing test.
<?php
namespace Tests\Feature;
use App\User;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ContactUserTest extends TestCase
{
/** @test */
public function it_does_send_a_mail_from_one_user_to_another()
{
$user_from = factory(User::class)->create();
$user_to = factory(User::class)->create();
Mail::fake();
$response = $this->post(route('contactUserMail'), [
'user_to' => $user_to,
'subject' => 'My subject',
'body' => 'My body',
]);
$response->assertSuccessful();
Mail::assertSent(ContactUserMail::class);
}
}
- I first create two users, the sender and the receiver (
$user_to
) - We will use the logged in user as the sender, so we don't need to send this info with the request
-
Mail::fake()
will swap the actualMail
class with a test library. No real mails will be sent and we can make assertions on what should happen - I decided that sending a mail should be a
POST
request from a form and haveuser_to
,subject
andbody
arguments. I will get theuser_from
automatically from the currently logged in user to prevent that you can send in someone else's name -
$response->assertSuccessful()
will check whether thePOST
request was successful - Finally, I check whether the Mail class
ContactUserMail
was sent
As expected, there are a couple of errors, but let phpunit
tell us what's wrong…
Just run phpunit
within your project - with Laravel 7+ you can also use php artisan test
.
Problem 01: No database being used
The error: SQLSTATE[HY000]: General error: 1 no such table: users
Our test is not using the database yet, let's add the RefreshDatabase
trait to solve this.
Note: I will show git diffs for solving the problems. This diff compares before a/
and after b/
. A +
means that something was added, a -
shows deleted lines.
diff --git a/tests/Feature/ContactUserTest.php b/tests/Feature/ContactUserTest.php
index 89de017..a611b89 100644
--- a/tests/Feature/ContactUserTest.php
+++ b/tests/Feature/ContactUserTest.php
@@ -3,11 +3,14 @@
namespace Tests\Feature;
use App\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ContactUserTest extends TestCase
{
+ use RefreshDatabase;
+
/** @test */
public function it_does_send_a_mail_from_one_user_to_another()
{
Problem 02: Route not defined
The error: Symfony\Component\Routing\Exception\RouteNotFoundException: Route [contactUserMail] not defined.
Well, we called a route that does not exist yet. Let's create it:
diff --git a/routes/web.php b/routes/web.php
index b130397..4ecc7b7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -16,3 +16,7 @@ use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
+
+Route::post('contactUserMail', function() {
+ return true;
+})->name('contactUserMail');
This route may seem stupid, because I just return true
, but for now it is just important to solve the error that phpunit
showed.
Problem 03: Mail was not sent
The error: The expected [Tests\Feature\ContactUserMail] mailable was not sent.
Ok, let's create the email first and leave as is:
php art make:mail ContactUserMail -m emails.contactUserMail
This will create the mail class and a markdown template.
We have to update the route to actually send the mail with Mail::to('test@test.com')->send(new ContactUserMail);
. Again, the goal is not to be right, but to get the test passing.
diff --git a/routes/web.php b/routes/web.php
index 4ecc7b7..9ed75ee 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,7 @@
<?php
+use App\Mail\ContactUserMail;
+use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Route;
/*
@@ -18,5 +20,6 @@ Route::get('/', function () {
});
Route::post('contactUserMail', function() {
+ Mail::to('test@test.com')->send(new ContactUserMail);
return true;
})->name('contactUserMail');
Lastly, we need to import the Mail class to the test:
diff --git a/tests/Feature/ContactUserTest.php b/tests/Feature/ContactUserTest.php
index a611b89..4d208a6 100644
--- a/tests/Feature/ContactUserTest.php
+++ b/tests/Feature/ContactUserTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature;
+use App\Mail\ContactUserMail;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
Run phpunit
again and… ✔ It does send a mail from one user to another
.
Congratulations, our test is passing, we are sending the mail as expected.
But wait, we did not check that user_from
and user_to
are right. Our test is incomplete.
This is also a common case, so no worries. Just add more assertions to your test:
diff --git a/tests/Feature/ContactUserTest.php b/tests/Feature/ContactUserTest.php
index fb6532d..64df550 100644
--- a/tests/Feature/ContactUserTest.php
+++ b/tests/Feature/ContactUserTest.php
@@ -28,6 +28,10 @@ class ContactUserTest extends TestCase
$response->assertSuccessful();
- Mail::assertSent(ContactUserMail::class);
+ Mail::assertSent(ContactUserMail::class, function($mail) use ($user_from, $user_to) {
+ $mail->build();
+ $this->assertEquals($user_from->email, $mail->from[0]['address']);
+ $this->assertTrue($mail->hasTo($user_to->email));
+ return true;
+ });
}
}
Run phpunit
again.
Problem 04: From is not right
Error: ErrorException: Trying to get property 'address' of non-object
Ok, we did not set the sender yet. Let's do this.
diff --git a/app/Mail/ContactUserMail.php b/app/Mail/ContactUserMail.php
index 017b66c..0577a18 100644
--- a/app/Mail/ContactUserMail.php
+++ b/app/Mail/ContactUserMail.php
@@ -28,6 +28,6 @@ class ContactUserMail extends Mailable
*/
public function build()
{
- return $this->markdown('emails.contactUserMail');
+ return $this->from('sender@test.com')->markdown('emails.contactUserMail');
}
}
diff --git a/tests/Feature/ContactUserTest.php b/tests/Feature/ContactUserTest.php
index d47f85f..947757c 100644
--- a/tests/Feature/ContactUserTest.php
+++ b/tests/Feature/ContactUserTest.php
@@ -15,7 +15,9 @@ class ContactUserTest extends TestCase
/** @test */
public function it_does_send_a_mail_from_one_user_to_another()
{
- $user_from = factory(User::class)->create();
+ $user_from = factory(User::class)->create([
+ 'email' => 'sender@test.com',
+ ]);
$user_to = factory(User::class)->create();
}
}
Problem 05: From is not right
Error: Failed asserting that false is true
(ContactUserTest.php:36
)
So, now the to
is not correct. Of course, we did not specify the email in the factory, so we have differing emails. Let's fix this!
diff --git a/tests/Feature/ContactUserTest.php b/tests/Feature/ContactUserTest.php
index 9f2b79d..3f72373 100644
--- a/tests/Feature/ContactUserTest.php
+++ b/tests/Feature/ContactUserTest.php
@@ -18,7 +18,9 @@ class ContactUserTest extends TestCase
$user_from = factory(User::class)->create([
'email' => 'sender@test.com',
]);
- $user_to = factory(User::class)->create();
+ $user_to = factory(User::class)->create([
+ 'email' => 'test@test.com',
+ ]);
Mail::fake();
Run phpunit
again and boom, from
and to
work as expected!
But subject
is still missing. Add an assertion to the test:
diff --git a/tests/Feature/ContactUserTest.php b/tests/Feature/ContactUserTest.php
index 3f72373..9840e1b 100644
--- a/tests/Feature/ContactUserTest.php
+++ b/tests/Feature/ContactUserTest.php
@@ -36,6 +36,7 @@ class ContactUserTest extends TestCase
$mail->build();
$this->assertEquals($user_from->email, $mail->from[0]['address']);
$this->assertTrue($mail->hasTo($user_to->email));
+ $this->assertEquals('My subject', $mail->subject);
return true;
});
}
Problem 05: Subject not working
Error: TypeError: Argument 2 passed to PHPUnit\Framework\Assert::assertTrue() must be of the type string, null given
(ContactUserTest.php:39
)
This simply means that $mail->subject
is empty.
Let's solve this:
diff --git a/app/Mail/ContactUserMail.php b/app/Mail/ContactUserMail.php
index 0577a18..1e7258f 100644
--- a/app/Mail/ContactUserMail.php
+++ b/app/Mail/ContactUserMail.php
@@ -28,6 +28,6 @@ class ContactUserMail extends Mailable
*/
public function build()
{
- return $this->from('sender@test.com')->markdown('emails.contactUserMail');
+ return $this->from('sender@test.com')->subject('My subject')->markdown('emails.contactUserMail');
}
}
Run phpunit
again and yay, the test is passing. You can be confident now that an email is sent with the expected from
, to
and subject
.
Let's call it a day for now and tap yourself on the shoulder, this was massive.
There will be a part 2 because, to be honest, we did not completely solve it. Right now, all the values are hard coded and not coming from a user's request. We will tackle this soon.