How to write Tests for legacy projects in PHP using PHPUnit


This is my first article which I started writing it in English, Actually, I had a website with the name of code-design but unfortunately, I have forced to change the domain. so now I'm here with a new domain. hope you'll enjoy the article, let's start without any further ado.

You started working on a project which you don't know really what's going on in the project completely, you still in the investigation state. but you know what is the concept and logic behind the scene because another developer who wants to leave the job was explaining for you what's going on in a very superficial way. 

You'll start digging into the code and next week you'll start with your first task, The task is more or less about sending email to the customer. we need to have a header and footer for all of the emails that we sent to our customers. Mmm, well that not a very hard task I guess. so will start digging into the project and yeah you'll find 10 different places which they are using different ways to sends an email. But at least they are using one package for sending an email and that's a good point to start from. 

Here how is the steps:

  • Find those 10 places which send email
  • Write the test for each of them
  • Start playing around with the code (Refactoring it)

We assume that the project has no vendor folder and all of the packages have been installed manually In some folder and all of them have been included in each file specifically.

 Now let's take a look at the beautiful code below and see how we gonna write tests for it.

public static function send_mail($email, $token)
{

    $mail = new PHPMailer();
    $mail->IsSMTP();                                     
    $mail->Host = "localhost";  
    $mail->SMTPAuth = true;     
    $mail->Username = "[email protected]";  
    $mail->Password = "********";
    $mail->From = "[email protected]";
    $mail->FromName = "test";
    $mail->Timeout = 120;
    $mail->AddAddress($email);
    $mail->IsHTML(true);
    $mail->Subject = "Account Confirmation";
    $mail->Body = getBodyContent($email, $token);
    $mail->AltBody = "This is the body in plain text for non-HTML mail clients";
    return $mail->Send();
}

The code is working pretty fine and now we need to write a test for this function. The first step is to install PHPUnit with the following command 

composer require phpunit/phpunit

The above command will install the latest version of PHPUnit if you want to have a specific version for your project you can search for that version and install the specific one.

After installing PHPUnit, we need to have our directories for writing tests, I'll share the convention directories to write tests. but you can have your own directories as you like.

 

 

As you can see we have two folders for writing tests, unit and functional. We could start with a unit test first. so we are going to create a new file with the name of AdminTest.php in the Unit folder. why I need to call it AdminTest, because the function sendEmail which is one of those 10 functions in the whole project is located in Admin.php file. so yeah we create a new file with the name of AdminTest.php and then the code in AdminTest.php will be something like this

<?php
namespace Unit\Panel;
use PHPUnit\Framework\TestCase;

class AdminTest extends TestCase
{

}

All of the test classes are going to extend the TestCase class which is the heart of PHPUnit.

Now we should write our first test scenario for our legacy project.

I can start with calling that method but before calling that method I need to mock phpMailer class. 

Why do we need to mock Phpmailer, since in testing we are never going to let the tests to call real third party apis. 

Now, how are we gonna mock that class, it's possible to do it with PHPUnit?

Well, PHPUnit is a very flexible frame-work but unfortunately for our testing purpose is not going to work. so we need to use another framework which it called Mockery, to install Mockery you need to run this command

composer require --dev mockery/mockery

After installing mockery, we are ready to write our first test scenario. in this test scenario, we are going to mock the real PHP-mailer class and expect some methods to be called.

/**
* @test
*/
public function send_mail_should_call_send_with_specific_parameters()
{
    $mock = \Mockery::mock('overload:'.PHPMailer::class);
    $mock->shouldReceive('IsSMTP')->once();
    $mock->shouldReceive('AddAddress')->once()->with('[email protected]');
    $mock->shouldReceive('IsHTML')->once()->with(true);
    $mock->shouldReceive('Send')->andReturn(true);
    $this->assertTrue(send_mail('[email protected]', str_random()));
}

Mmm, it seems not very difficult to write tests? 

We have used the overload method to mock the PHP-mailer class, why we used that overload, it's because of hard dependencies. As you can see in the prev code, a new instance had been created in the function. so this is the only way we can mock a hard dependency. After mocking php-mailer we are going to call should receive method for each method that we can see in the SendMail function.

But why we are expecting every method to be called. because we are writing a test for sending an email, we need to somehow stop other users from modifying the sendMail method unless they want to add something new, then they need to add more tests.

If you really don't want to expect every method to be called, you can use spy instead of mock.

Okay, now we have written our first test, what other scenarios you can think about it?

Well, we can have negative scenarios also, but for now, let us just stick to what we have and make it short as much as possible. Let's assume we have written all of the test scenarios and we have done it for all of those 10 places which have been used in the project, now it's the time to add that header and footer to the email contents.

Keep in mind that we are not in refactoring mode, we are now only going to add that header and footer. so no need to refactor those 10 places. lets just have them as they are. and think about the solution for a min. 

 

  • The first solution: would be that we should go to each of those 10 functions which I have mentioned and add the header and the footer to the body. 
  • The second solution: could be this, we can define our MailerClass which extends The general mailer class. this way we will have our custom mailer class with the header and the footer and one more benefit which we can gain is that in the future If we had a new task regarding changing any value we can just go to our Mailer custom class and add or remove it in only one place.

 

Lets start with the second solution.

class CustomPhpMailer extends PHPMailer{

    private $header = '<header>This is the header</header> ';

    private $footer = '<footer>This is the footer</footer> ';

    function __construct($exceptions = false)
    {
        parent::__construct($exceptions);
    }

    function send()
    {
        $this->Body = $this->header.'</br>'.$this->footer;
        return parent::send();
    }
}

As you can see in the above code when we try to trigger the send method, the header and the footer will be added to the body. so after the user call send method we first add the header and then the footer contents then we call send method from the parent class. Now our tests going to fail, so we need to use our new class name in the testing.

/**
* @test
*/
public function send_mail_should_call_send_with_specific_parameters()
{
    $mock = \Mockery::mock('overload:'.CustomPhpMailer::class);
    $mock->shouldReceive('IsSMTP')->once();
    $mock->shouldReceive('AddAddress')->once()->with('[email protected]');
    $mock->shouldReceive('IsHTML')->once()->with(true);
    $mock->shouldReceive('Send')->andReturn(true);
    $this->assertTrue(send_mail('[email protected]', str_random()));
}

And the code that sends email should be something like this 

public static function send_mail($email, $token)
{

    $mail = new CustomPhpMailer();
}

Now we have our CustomPhpMailer and now we can write tests. Okay, what is the scenario for that test? 

The first scenario could be that we want to know after we call the send method, does it works correctly and it sets the header and the footer into the body?

For achieving this scenario we need to change our CustomPhpMailer class a little bit, but why we should do that? The problem is the send method, if we want to test the send method then it gonna call send from the parent class which leads to sends an email. so for this problem, I have a solution. we can define a new method in our CustomPhpMailer and just make use of PartialMocking.

CustomPhpMailer class should be changed to the following code.

class CustomPhpMailer extends PHPMailer{

    private $header = '<header>This is the header</header> ';

    private $footer = '<footer>This is the footer</footer> ';

    function __construct($exceptions = false)
    {
        parent::__construct($exceptions);
    }

    function send()
    {
        $this->Body = $this->header.$this->Body.$this->footer;
        return $this->sendEmail();
    }

    public function sendEmail(){
        return parent::send();
    }

}

Now we can write a test for this class, we can use partial mocking to mock the class.

/**
* @test
*/
public function CustomPhpMailer_should_set_the_header_and_the_footer_when_send_method_get_called()
{
    $customPhpMailer = \Mockery::mock(CustomPhpMailer::class)->makePartial();
    $customPhpMailer->shouldReceive('sendEmail')->once()->andReturn(true);
    $customPhpMailer->Body = 'This is the body';
    $customPhpMailer->send();

    $this->assertEquals($this->header.'This is the body'.$this->footer, $customPhpMailer->Body);
}

Partial mocking as the name describes it's basically only mock the expected methods, and the rest of other methods are working as they are. so we just mocked the send email method and left other methods to themselves. now if we called send we should receive the expected results, which we are getting it.

 

 

As you saw, working with legacy code is very hard. you need to use patterns and solutions which even the creator of that testing framework has forbidden them. but for moving forward and achieving which cant be achieved without testing it's necessary. now we can say after implementing this task we can easily refactor the whole part of sending an email. 

 

Hope you enjoy the article :)