Skip to content

Commit

Permalink
Add Mail Testing (#496)
Browse files Browse the repository at this point in the history
* Adding interacts with mail to allow for easy testing against mail

* CHANGELOG

* Add assertMailSentCount()

* PHPCS fix

* Testing CI

* Testing CI

* Testing CI
  • Loading branch information
srtfisher committed Feb 21, 2024
1 parent cd786f3 commit 4b5aff2
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 63 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

`composer require --dev phpunit/phpunit:^9 nunomaduro/collision:^6`
- Adds database-specific collections with storage of the `found_rows` value.
- Added testing against `wp_mail()` calls.

### Changed

Expand Down
1 change: 1 addition & 0 deletions src/mantle/testing/autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

require_once __DIR__ . '/preload.php';
require_once __DIR__ . '/helpers/helpers-http-response.php';
require_once __DIR__ . '/mail/helpers.php';

/**
* Retrieve an instance of the Installation Manager
Expand Down
2 changes: 2 additions & 0 deletions src/mantle/testing/class-test-case.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Mantle\Testing\Concerns\Interacts_With_Container;
use Mantle\Testing\Concerns\Interacts_With_Cron;
use Mantle\Testing\Concerns\Interacts_With_Hooks;
use Mantle\Testing\Concerns\Interacts_With_Mail;
use Mantle\Testing\Concerns\Interacts_With_Requests;
use Mantle\Testing\Concerns\Makes_Http_Requests;
use Mantle\Testing\Concerns\Network_Admin_Screen;
Expand Down Expand Up @@ -54,6 +55,7 @@ abstract class Test_Case extends BaseTestCase {
Interacts_With_Container,
Interacts_With_Cron,
Interacts_With_Hooks,
Interacts_With_Mail,
Interacts_With_Requests,
Makes_Http_Requests,
MatchesSnapshots,
Expand Down
127 changes: 127 additions & 0 deletions src/mantle/testing/concerns/trait-interacts-with-mail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php
/**
* Interacts_With_Mail trait file.
*
* @package Mantle
*
* @phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
*/

namespace Mantle\Testing\Concerns;

use Mantle\Support\Collection;
use Mantle\Testing\Doubles\MockPHPMailer;
use Mantle\Testing\Mail\Mail_Message;
use Mantle\Testing\Mail\Mock_Mailer;

use function Mantle\Support\Helpers\collect;

/**
* Concern for interacting with the WordPress wp_mail() function.
*
* @mixin \Mantle\Testing\Test_Case
*/
trait Interacts_With_Mail {
/**
* Setup the trait and replace the global phpmailer instance with a mock instance.
*/
public function interacts_with_mail_set_up(): void {
reset_phpmailer_instance();
}

/**
* Assert that an email was sent to the given recipient.
*
* @param (callable(\Mantle\Testing\Mail\Mail_Message): bool)|string|null $address_or_callback The email address to check for, or a callback to perform custom assertions.
*/
public function assertMailSent( string|callable|null $address_or_callback = null ): void {
$mailer = tests_retrieve_phpmailer_instance();

if ( ! ( $mailer instanceof Mock_Mailer ) ) {
$this->fail( 'Mail instance is not a MockPHPMailer instance.' );
}

if ( is_null( $address_or_callback ) ) {
$this->assertNotEmpty( $mailer->mock_sent, 'No emails were sent.' );
return;
}

$this->assertNotEmpty(
$this->getSentMail( $address_or_callback ),
is_string( $address_or_callback ) ? "No email was sent to [{$address_or_callback}]." : 'No email was sent matching the given callback function.'
);
}

/**
* Assert that an email was not sent to the given recipient.
*
* @param (callable(\Mantle\Testing\Mail\Mail_Message): bool)|string|null $address_or_callback The email address to check for, or a callback to perform custom assertions.
*/
public function assertMailNotSent( string|callable|null $address_or_callback = null ): void {
$mailer = tests_retrieve_phpmailer_instance();

if ( ! ( $mailer instanceof Mock_Mailer ) ) {
$this->fail( 'Mail instance is not a MockPHPMailer instance.' );
}

if ( is_null( $address_or_callback ) ) {
$this->assertEmpty( $mailer->mock_sent, 'An email was sent.' );
return;
}

$this->assertEmpty(
$this->getSentMail( $address_or_callback ),
is_string( $address_or_callback ) ? "An email was sent to [{$address_or_callback}]." : 'An email was sent matching the given callback function.'
);
}

/**
* Assert that a specific number of emails were sent.
*
* @param int $expected_count The expected number of emails sent.
* @param (callable(\Mantle\Testing\Mail\Mail_Message): bool)|string|null $address_or_callback The email address to check for, or a callback to perform custom assertions.
*/
public function assertMailSentCount( int $expected_count, string|callable|null $address_or_callback = null ): void {
$mailer = tests_retrieve_phpmailer_instance();

if ( ! ( $mailer instanceof Mock_Mailer ) ) {
$this->fail( 'Mail instance is not a MockPHPMailer instance.' );
}

if ( is_null( $address_or_callback ) ) {
$actual = count( $mailer->mock_sent );
$this->assertCount( $expected_count, $mailer->mock_sent, "Expected {$expected_count} emails to be sent, but only {$actual} were sent." );
return;
}

$sent_mail = $this->getSentMail( $address_or_callback );
$count = count( $sent_mail );

$this->assertCount( $expected_count, $sent_mail, "Expected {$expected_count} emails to be sent, but only {$count} were sent." );
}

/**
* Retrieve the sent mail for a given to address or callback function that
* performs a match against sent mail.
*
* @param (callable(\Mantle\Testing\Mail\Mail_Message): bool)|string $address_or_callback The email address to check for, or a callback to perform custom assertions.
* @return Collection<int, \Mantle\Testing\Mail\Mail_Message>
*/
protected function getSentMail( string|callable $address_or_callback = null ): Collection {
$mailer = tests_retrieve_phpmailer_instance();

if ( ! ( $mailer instanceof Mock_Mailer ) ) {
$this->fail( 'Mail instance is not a MockPHPMailer instance.' );
}

return collect( $mailer->mock_sent )->filter(
function ( Mail_Message $message ) use ( $address_or_callback ) {
if ( is_string( $address_or_callback ) ) {
return $message->sent_to( $address_or_callback );
}

return $address_or_callback( $message );
}
);
}
}
44 changes: 44 additions & 0 deletions src/mantle/testing/mail/class-mail-message.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
/**
* Mail_Message class file
*
* @package Mantle
*/

namespace Mantle\Testing\Mail;

use function Mantle\Support\Helpers\collect;

/**
* Mail Message Record
*/
class Mail_Message {
/**
* Constructor.
*
* @param array $to The recipient of the email.
* @param array $cc The CC recipient of the email.
* @param array $bcc The BCC recipient of the email.
* @param string $subject The subject of the email.
* @param string $body The body of the email.
* @param string $header The header of the email.
*/
public function __construct(
public readonly array $to,
public readonly array $cc,
public readonly array $bcc,
public readonly string $subject,
public readonly string $body,
public readonly string $header
) {}

/**
* Check if the email was sent to the given recipient.
*
* @param string $address The email address to check for.
* @return bool
*/
public function sent_to( string $address ): bool {
return collect( $this->to )->pluck( 0 )->contains( $address );
}
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
<?php // phpcs:disable
<?php
/**
* Mock_Mailer class file
*
* phpcs:disable WordPress.NamingConventions.ValidVariableName
*
* @package Mantle
*/

namespace Mantle\Testing\Doubles;
namespace Mantle\Testing\Mail;

if ( ! file_exists( ABSPATH . '/wp-includes/PHPMailer/PHPMailer.php' ) ) {
// todo: add link to documentation when it is available.
echo "Core PHPMailer file not found. Is WordPress installed properly at " . ABSPATH . "?\n";
echo 'Core PHPMailer file not found. Is WordPress installed properly at ' . ABSPATH . "?\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
return;
}

require_once ABSPATH . '/wp-includes/PHPMailer/PHPMailer.php';

class MockPHPMailer extends \PHPMailer\PHPMailer\PHPMailer {
var $mock_sent = array();
/**
* Mock PHPMailer class.
*
* @package Mantle
*/
class Mock_Mailer extends \PHPMailer\PHPMailer\PHPMailer {
/** @var Mail_Message[] */
public array $mock_sent = [];

function preSend() {
/**
* Override preSend() method.
*/
public function preSend() {
$this->Encoding = '8bit';

return parent::preSend();
}

/**
* Override postSend() so mail isn't actually sent.
*/
function postSend() {
$this->mock_sent[] = array(
'to' => $this->to,
'cc' => $this->cc,
'bcc' => $this->bcc,
'header' => $this->MIMEHeader . $this->mailHeader,
'subject' => $this->Subject,
'body' => $this->MIMEBody,
public function postSend() {
$this->mock_sent[] = new Mail_Message(
to: $this->to,
cc: $this->cc,
bcc: $this->bcc,
subject: $this->Subject,
body: $this->Body,
header: $this->MIMEHeader . $this->mailHeader
);

return true;
Expand All @@ -37,39 +54,32 @@ function postSend() {
/**
* Decorator to return the information for a sent mock.
*
* @since 4.5.0
*
* @param int $index Optional. Array index of mock_sent value.
* @return object
* @return object|false
*/
public function get_sent( $index = 0 ) {
$retval = false;
if ( isset( $this->mock_sent[ $index ] ) ) {
$retval = (object) $this->mock_sent[ $index ];
}
return $retval;
return isset( $this->mock_sent[ $index ] ) ? (object) $this->mock_sent[ $index ] : false;
}

/**
* Get a recipient for a sent mock.
*
* @since 4.5.0
*
* @param string $address_type The type of address for the email such as to, cc or bcc.
* @param int $mock_sent_index Optional. The sent_mock index we want to get the recipient for.
* @param int $recipient_index Optional. The recipient index in the array.
* @return bool|object Returns object on success, or false if any of the indices don't exist.
*/
public function get_recipient( $address_type, $mock_sent_index = 0, $recipient_index = 0 ) {
public function get_recipient( string $address_type, int $mock_sent_index = 0, $recipient_index = 0 ) {
$retval = false;
$mock = $this->get_sent( $mock_sent_index );

if ( $mock ) {
if ( isset( $mock->{$address_type}[ $recipient_index ] ) ) {
$address_index = $mock->{$address_type}[ $recipient_index ];
$recipient_data = array(
$recipient_data = [
'address' => ( isset( $address_index[0] ) && ! empty( $address_index[0] ) ) ? $address_index[0] : 'No address set',
'name' => ( isset( $address_index[1] ) && ! empty( $address_index[1] ) ) ? $address_index[1] : 'No name set',
);
];

$retval = (object) $recipient_data;
}
Expand All @@ -78,35 +88,3 @@ public function get_recipient( $address_type, $mock_sent_index = 0, $recipient_i
return $retval;
}
}

/**
* Helper method to return the global phpmailer instance defined in the bootstrap
*
* @since 4.4.0
*
* @return object|bool
*/
function tests_retrieve_phpmailer_instance() {
$mailer = false;
if ( isset( $GLOBALS['phpmailer'] ) ) {
$mailer = $GLOBALS['phpmailer'];
}
return $mailer;
}

/**
* Helper method to reset the phpmailer instance.
*
* @since 4.6.0
*
* @return bool
*/
function reset_phpmailer_instance() {
$mailer = tests_retrieve_phpmailer_instance();
if ( $mailer ) {
$GLOBALS['phpmailer'] = new MockPHPMailer( true );
return true;
}

return false;
}
29 changes: 29 additions & 0 deletions src/mantle/testing/mail/helpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
/**
* Mail helper methods.
*
* Intentionally not namespaced to mirror core.
*
* phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound
* phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited
*
* @package Mantle
*/

use Mantle\Testing\Mail\Mock_Mailer;

/**
* Helper method to return the global phpmailer instance defined in the bootstrap
*
* @return \PHPMailer\PHPMailer\PHPMailer|bool
*/
function tests_retrieve_phpmailer_instance(): \PHPMailer\PHPMailer\PHPMailer|bool {
return $GLOBALS['phpmailer'] ?? false;
}

/**
* Helper method to reset the phpmailer instance.
*/
function reset_phpmailer_instance(): void {
$GLOBALS['phpmailer'] = new Mock_Mailer( true );
}
4 changes: 0 additions & 4 deletions src/mantle/testing/wordpress-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,6 @@
$multisite = $multisite || ( defined( 'WP_TESTS_MULTISITE' ) && WP_TESTS_MULTISITE );
$multisite = $multisite || ( defined( 'MULTISITE' ) && MULTISITE );

// Override the PHPMailer.
require_once __DIR__ . '/doubles/class-mockphpmailer.php';
$phpmailer = new MockPHPMailer( true );

// Include a WP_UnitTestCase class to allow for easier transition to the testing
// framework.
if ( ! Utils::env( 'DISABLE_WP_UNIT_TEST_CASE_SHIM', false ) ) {
Expand Down
Loading

0 comments on commit 4b5aff2

Please sign in to comment.