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);
    }
}

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.

Did this help you?