Skip to content

Unit Testing

Zane Hooper edited this page Jun 30, 2017 · 10 revisions

It is highly recommended that all packages make extensive use of tests to prevent bugs.

You can add a tests directory to any directory in the app folder of the package. You can run all the package's tests using phpunit or run a specific test/directory of tests using phpunit app/Report for example. scp-package-seed comes with a phpunit.xml already in place that you are welcome to modify as necessary.

All tests should extend App\Support\Test\TestCase to make use of the methods there, e.g. auth, authNewClient, etc.

Auth

Numerous authentication methods are available:

    /**
     * @param Authenticatable $user
     *
     * @return Authenticatable
     */
    public function auth(Authenticatable $user);

    /**
     * @return $this
     */
    public function logout();

    /**
     * Authorize as a new Administrator.
     *
     * @param string $username
     * @param string $password
     *
     * @return Admin
     */
    public function authNewAdmin($username = '_testing_', $password = '_testing_');

    /**
     * Authorize as a client.
     *
     * @return Client
     */
    public function authNewClient();

Factories

Factories are useful for generating Models. You can use factories like so:

public function setUp()
{
    parent::setUp();

    $this->server = factory(\App\Server\Server::class)->create([
        // This attribute map will be applied to the Server automatically,
        // and the server will be created and saved for you.
        'billing_date' => '<some value>',
    ]);
}

You should be sure to delete all the testing items that were created at the end of each test:

public function tearDown()
{
    parent::tearDown();

    $this->delete(
        $this->server->url()
    );
}

Using the API to delete ($this->delete(...)) the items you create (in the right order) will automatically delete any relationships that are preventing the Model from being deleted.

Mocks

Mocks are classes that pretend to be another class. So if you had a Mock Carbon instance and called the gt method on it, the Mock would throw an error that the gt method is not mocked. If you ran ->shouldReceive('gt'), the Mock would allow the gt function to be run. In this way, we can specify the exact methods we expect to be called on any mocked object when it is used by a Service we are testing. Also, we can check that the arguments sent to these methods are correct and we can specify return values for the mocked methods.

Unit tests should only verify that one single class works as expected. If a class has any injected dependencies on other classes, those classes should be mocked. If the class fires any events, for instance, it should be done through a mocked EventDispatcher.

So the basic idea behind this form of unit testing is:

  • Prove that a single service class works as expected - i.e. when you call a certain method it performs the correct logic.
  • Prove that every class using that class works as expected - i.e. when you POST to some controller, it calls a certain method on your service. Since you've Mocked your service class, the method is not actually called but you can verify that it is called in the controller with the right arguments using the Mock.

Here's an example Unit Test using mocks:

class Service
{
    public function __construct(OtherService $other)
    {
        $this->other = $other;
    }

    public function doSomething(Carbon $date)
    {
        if ($date->gt($this->other->someDate()) {
            $this->other->dateIsGreater($date);
            return;
        }

        $this->other->dateIsLess($date);
    }
}

class ServiceTest extends TestCase
{
    public function setUp()
    {
        parent::setUp();

        $this->service = new Service(
            $this->other = Mockery::mock(OtherService::class)
        );
    }

    public function testDateGreater()
    {
        $date = Mockery::mock(Carbon::class);
        $otherDate = Mockery::mock(Carbon::class);

        $this->service->doSomething($date); // This errors, don't do this yet! `gt` is not mocked.

        $this->other->shouldReceive('someDate')->andReturn($otherDate);
        $date
            ->shouldReceive('gt')
            ->with($otherDate)
            ->andReturn(true)
        ;
        
        $this->other->shouldReceive('dateIsGreater')->with($date);
    }

    public function testDateGreater()
    {
        $date = Mockery::mock(Carbon::class);
        $otherDate = Mockery::mock(Carbon::class);

        $this->other->shouldReceive('someDate')->andReturn($otherDate);
        $date
            ->shouldReceive('gt')
            ->with($otherDate)
            ->andReturn(false)
        ;
        
        $this->other->shouldReceive('dateIsLess')->with($date);
    }
}

In this way, we are able to verify that the logic in the Service works exactly as expected.

But wait, now my objects aren't getting modified by the Service's dependencies!

In the example above, if OtherService::dateIsLess normally modifies $date, we will no longer have that modification happening in the unit test. This is actually good behavior. We want a single unit test to only test a single class' logic. If we need the modification from dateIsLess to happen to $date we need to modify it ourselves in the unit test. OtherService should be tested by a separate TestCase that verifies that it works as expected. If OtherService and Service are both tested properly, we will detect any change in the logic of either because the unit tests will fail. Then, we can adjust the unit tests to work with the new logic.