How to Test Transactional Emails Before Production
Transactional emails are the emails your application sends in response to a user action: signup confirmations, password resets, order receipts, shipping notifications, two-factor authentication codes. Unlike marketing emails, they are expected immediately. A user who clicks "Reset Password" and waits five minutes without receiving an email will assume your application is broken.
Despite this, transactional email testing is often an afterthought. Developers verify the happy path manually β "I got the email, looks fine" β and move on. This leads to production bugs that are painful to debug: wrong data in templates, emails silently failing for certain user types, broken rendering in Outlook, or queued jobs that never fire.
This guide covers a systematic approach to testing transactional emails, from triggering logic to content verification to production monitoring.
Why Transactional Emails Need Their Own Testing Strategy
Marketing emails are sent in bulk, usually through a dedicated platform with built-in preview and testing tools. Transactional emails are different:
- They are triggered by application logic, not manual sends.
- They contain dynamic, per-user data (names, order totals, unique links).
- They often have conditional sections (show tracking info only if shipped).
- Delivery timing matters β a 2FA code that arrives 10 minutes late is useless.
- They run through your application's queue system, which adds failure points.
Testing transactional emails means testing your application code, your email templates, your SMTP infrastructure, and your queue system. Each layer can fail independently.
Layer 1: Testing the Trigger
The first thing to verify is that the right event sends the right email. This is pure application logic testing.
In Laravel, transactional emails are typically sent via Mailables or Notifications. You can assert that they were dispatched without actually sending them:
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;
it('sends an order confirmation email after checkout', function () {
Mail::fake();
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
// Trigger the action that should send the email
app(CheckoutService::class)->complete($order);
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($user, $order) {
return $mail->hasTo($user->email)
&& $mail->order->id === $order->id;
});
});
For notifications:
use App\Notifications\PasswordResetNotification;
use Illuminate\Support\Facades\Notification;
it('sends a password reset notification', function () {
Notification::fake();
$user = User::factory()->create();
app(PasswordResetService::class)->sendResetLink($user);
Notification::assertSentTo($user, PasswordResetNotification::class);
});
What to Test at This Layer
- The correct Mailable or Notification class is dispatched.
- It is sent to the correct recipient(s).
- It is not sent when it should not be (e.g., unverified users, deactivated accounts).
- The correct data is attached (order details, user name, reset token).
- Multiple emails are sent when required (e.g., both customer and admin get notified).
Negative Cases Matter
it('does not send a confirmation email for failed payments', function () {
Mail::fake();
$order = Order::factory()->create(['payment_status' => 'failed']);
app(CheckoutService::class)->complete($order);
Mail::assertNotSent(OrderConfirmation::class);
});
Layer 2: Testing Email Content
Once you know the right email is triggered, you need to verify its content. Dynamic data, conditional sections, and personalization are all sources of bugs.
Rendering the Mailable
Laravel lets you render a Mailable to a string for assertions:
it('includes the order total in the confirmation email', function () {
$order = Order::factory()->create([
'total' => 4999, // cents
]);
$mailable = new OrderConfirmation($order);
$html = $mailable->render();
expect($html)->toContain('$49.99');
expect($html)->toContain($order->user->name);
});
Testing Conditional Sections
it('shows tracking information when the order is shipped', function () {
$order = Order::factory()->create([
'status' => 'shipped',
'tracking_number' => '1Z999AA10123456784',
]);
$html = (new ShippingNotification($order))->render();
expect($html)->toContain('1Z999AA10123456784');
expect($html)->toContain('Track Your Package');
});
it('omits tracking info when the order is processing', function () {
$order = Order::factory()->create([
'status' => 'processing',
'tracking_number' => null,
]);
$html = (new ShippingNotification($order))->render();
expect($html)->not->toContain('Track Your Package');
});
Testing Subject Lines
it('includes the order number in the subject', function () {
$order = Order::factory()->create();
$mailable = new OrderConfirmation($order);
expect($mailable->envelope()->subject)
->toBe("Order #{$order->number} Confirmed");
});
Layer 3: Testing Delivery
Knowing that your application dispatches the correct email with the correct content is not enough. You also need to verify that the email actually gets delivered through your SMTP pipeline.
This is where SMTP sandboxes come in. An SMTP sandbox captures emails sent by your application without delivering them to real recipients. You get the full email β headers, body, attachments β in a safe environment.
Using an SMTP Sandbox
SendPit provides cloud-hosted SMTP sandbox mailboxes that your entire team can access. Configure your application to point at a SendPit mailbox during development:
MAIL_MAILER=smtp
MAIL_HOST=sandbox.sendpit.com
MAIL_PORT=2525
MAIL_USERNAME=your-mailbox-username
MAIL_PASSWORD=your-mailbox-password
MAIL_ENCRYPTION=tls
Every email your application sends now gets captured in your SendPit mailbox instead of reaching real inboxes. Your whole team can view captured emails, inspect headers, and verify content β without coordinating test email addresses or risking emails to real users.
Why Not Just Use Mailtrap or Log Driver?
The log mail driver writes emails to your Laravel log file. That works for solo development but falls apart with teams β there is no shared view, no way to inspect HTML rendering, and no header inspection.
SendPit provides shared mailboxes with team access, full header inspection (including authentication headers), and an API for automated verification. The free tier covers most development needs.
Testing SMTP Configuration
Write an integration test that actually sends through SMTP to your sandbox:
it('successfully delivers email through SMTP', function () {
// Use the real mail driver, not fake
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
Mail::to($user)->send(new OrderConfirmation($order));
// If no exception is thrown, SMTP accepted the message
expect(true)->toBeTrue();
});
This test catches SMTP configuration errors: wrong credentials, TLS issues, firewall rules blocking the port, and authentication failures.
Layer 4: Testing Queue Processing
Most production applications queue transactional emails for performance. This adds a failure point: the email is dispatched to the queue but may never be processed.
Testing That Emails Are Queued
use Illuminate\Support\Facades\Queue;
it('queues the welcome email', function () {
Queue::fake();
$user = User::factory()->create();
app(RegistrationService::class)->register($user);
Queue::assertPushed(\Illuminate\Mail\SendQueuedMailable::class);
});
Testing Queue Processing End-to-End
For integration tests, use the sync queue driver to process jobs immediately:
it('processes queued emails successfully', function () {
// Force sync queue for this test
config(['queue.default' => 'sync']);
Mail::fake();
$user = User::factory()->create();
app(RegistrationService::class)->register($user);
Mail::assertSent(WelcomeEmail::class);
});
Testing Retry Logic
If your queued email job fails (SMTP timeout, temporary provider outage), Laravel will retry it based on your job configuration. Test that your Mailable handles retries correctly:
it('retries failed email jobs', function () {
$mailable = new OrderConfirmation(Order::factory()->create());
// Assert the mailable is configured for retries
expect($mailable->tries)->toBe(3);
expect($mailable->backoff)->toBe([10, 60, 300]);
});
Layer 5: Testing HTML Rendering
Email clients render HTML differently. What looks perfect in Gmail may be broken in Outlook. This is the hardest layer to test automatically, but there are practical approaches.
Inline CSS
Most email clients strip <style> tags. Inline your CSS during the build process or use a library:
// In your Mailable
public function content(): Content
{
return new Content(
view: 'emails.order-confirmation',
);
}
Laravel automatically applies CSS inlining when using Markdown mailables or when configured with a CSS inliner package.
Structure Testing
Even without visual regression testing, you can assert structural correctness:
it('uses a table layout for the order items', function () {
$order = Order::factory()->hasItems(3)->create();
$html = (new OrderConfirmation($order))->render();
// Verify the table structure exists
expect($html)->toContain('<table');
expect($html)->toContain('</table>');
// Verify all items are rendered
foreach ($order->items as $item) {
expect($html)->toContain($item->name);
expect($html)->toContain(number_format($item->price / 100, 2));
}
});
Manual Verification Workflow
For visual checks, send test emails to your SendPit sandbox and review them in the web interface. SendPit renders the HTML email so you can visually inspect it across your team without setting up local email clients.
Building a Complete Testing Workflow
Here is a practical workflow that covers all five layers:
1. Unit Tests (Trigger + Content)
Run on every commit. Fast, no external dependencies.
// tests/Feature/Emails/OrderConfirmationTest.php
beforeEach(function () {
Mail::fake();
});
it('sends order confirmation on successful checkout', function () { /* ... */ });
it('does not send on failed payment', function () { /* ... */ });
it('includes correct order total', function () { /* ... */ });
it('includes all line items', function () { /* ... */ });
it('uses the correct subject line', function () { /* ... */ });
2. Integration Tests (Delivery + Queue)
Run in CI with a sandbox SMTP endpoint.
// tests/Feature/Emails/SmtpDeliveryTest.php
it('delivers order confirmation via SMTP', function () {
config(['mail.default' => 'smtp']);
// Uses SendPit sandbox credentials from .env.testing
$order = Order::factory()->create();
Mail::to('[email protected]')->send(new OrderConfirmation($order));
expect(true)->toBeTrue(); // No SMTP exception = success
});
3. Visual Review (Rendering)
Before each release, trigger key transactional emails against your SendPit sandbox and do a visual review. Check:
- Layout integrity
- Dynamic data renders correctly
- Links point to the right URLs
- Images load
- Plain text version is readable
4. Production Monitoring (Webhooks + Logging)
After deployment, monitor email delivery in production. Set up webhook endpoints that log delivery status:
// routes/api.php
Route::post('/webhooks/email-status', function (Request $request) {
Log::channel('email')->info('Email status update', [
'message_id' => $request->input('message_id'),
'event' => $request->input('event'), // delivered, bounced, complained
'recipient' => $request->input('recipient'),
'timestamp' => $request->input('timestamp'),
]);
return response()->json(['status' => 'ok']);
});
Track key metrics: delivery rate, bounce rate, time-to-delivery. Set up alerts for anomalies.
Automated Testing With SendPit's API
Beyond visual inspection, you can use SendPit's API to programmatically verify captured emails in your CI pipeline:
it('delivers email with correct content to sandbox', function () {
// Send through real SMTP to SendPit sandbox
config(['mail.default' => 'smtp']);
$order = Order::factory()->create();
Mail::to('[email protected]')->send(new OrderConfirmation($order));
// Query SendPit API for the captured email
$response = Http::withToken(config('services.sendpit.api_key'))
->get('https://api.sendpit.com/v1/mailboxes/' . config('services.sendpit.mailbox_id') . '/emails', [
'to' => '[email protected]',
'limit' => 1,
]);
$email = $response->json('data.0');
expect($email['subject'])->toContain("Order #{$order->number}");
expect($email['html_body'])->toContain($order->user->name);
});
Common Mistakes to Avoid
Testing only with Mail::fake(). Faking is essential for unit tests, but it skips the entire SMTP pipeline. You need at least some tests that send through a real SMTP endpoint.
Using real email addresses in tests. Even in staging environments, never send to real addresses. Use an SMTP sandbox. It takes one misconfigured environment variable to send test emails to real customers.
Ignoring the plain text version. Some email clients and accessibility tools prefer or require the plain text version. Test both HTML and plain text output.
Not testing with realistic data. An email template that works with "John" breaks with "Bartholomew Jedediah Worthington III" because the layout overflows. Use factories that generate varied, realistic data.
Skipping queue failure scenarios. If your Redis goes down, your queued emails will not send. Have a fallback strategy and test it.
Summary
Testing transactional emails properly requires testing at multiple layers:
- Trigger logic β the right event sends the right email (unit tests with
Mail::fake()). - Content β dynamic data, conditional sections, subject lines (render assertions).
- Delivery β SMTP configuration works end-to-end (integration tests with SMTP sandbox).
- Queue processing β jobs are dispatched and processed correctly.
- Rendering β HTML looks correct across email clients (visual review in sandbox).
Use an SMTP sandbox like SendPit to capture emails safely during development and CI. Write automated tests for the first four layers and establish a manual review process for rendering. Monitor delivery in production with webhooks and logging.
Transactional emails are part of your application's user experience. Test them like it.
Nikhil Rao
Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.
About SendPit →