I have a new client who came to me wanting to build out some new features on an existing Laravel 4.2 application. Unfortunately the codebase did not have any tests, wich complicates the process of implementing the new changes. As a sort of stop-gap measure, we have agreed to add some "retro-active" acceptance tests to make sure that the existing functionality isn't inadvertently broken.
Being on 4.2, it does not have access to all of the great testing conveniences found in Laravel 5.*, which is unfortunate because it means I didn't have access to any of the new test helpers. Codeception is a great alternative testing package that provides a wealth of functionality and it is already compatible with Laravel 4 - this seemed like the best choice for us. So far, it has been working quite well for us, but I did run into one particularly frustrating problem: Binding mocks of service classes to the IoC does not work "out of the box". This is frustrating if you have a service class that hits a third-party API and you don't want to have to hit that API each time you run your tests.
As it turns out, this problem is due to the Laravel4 Module re-initializing the Application object on each request. While it looks like this should work, it actually doesn't:
1 | $I = new FunctionalTester($scenario); |
2 | |
3 | $I->wantTo('upgrade a subscription plan'); |
4 | |
5 | $I->amActingAs('[email protected]'); // This is a custom helper method which sets the active user |
6 | |
7 | // Establish the necessary mocks |
8 | $mockBillingManager = Mockery::mock('Acme\Billing\StripeBillingManager'); |
9 | |
10 | // Make sure to set the expectations of the mock object before binding it to the IoC |
11 | $I->getApplication()->bind('Acme\Billing\StripeBillingManager', $mockBillingManager); |
12 | |
13 | $I->amOnRoute('billing.upgrade.form'); |
14 | |
15 | $I->submitForm('subscription-form', [ |
16 | 'plan' => 'premium', |
17 | 'token' => csrf_token() |
18 | ]); |
19 | |
20 | $I->seeInDatabase('users', ['email' => '[email protected]', 'plan' => 'premium'); |
When the amOnRoute()
method is executed, the Application is refreshed and all of your custom bindings are lost in favor of the bindings you establish in your service provider(s). This is triggerd by the doRequest()
method on the Codeception\Lib\Connector\Laravel4
class, which is used by default in the Laravel4 module. The solution that worked for me was to extend the Laravel4 module and its connector class. This allowed me to remove the offfending line from the doRequest()
method. Also, the Laravel4 module does not provide any methods for helping with temporary binding; creating a custom module allowed me to add some convenient methods to help with this.
Jordan Eldredge has a great article about writing custom Codeception modules. Using the methods he describes in his post we can create a custom Codeception Module and a new Connector library which is identical to the existing Laravel4 connector library. Only one change is required to the existing connector library, on line 47:
45 | // New class based on Codeception\Lib\Connector\Laravel4 |
46 | |
47 | class CustomLaravel4Connector { |
48 | |
49 | // Most everything remains the same... |
50 | |
51 | /** |
52 | * @param Request $request |
53 | * @return Response |
54 | */ |
55 | protected function doRequest($request) |
56 | { |
57 | //$this->initialize(); -- Remove this line |
58 | |
59 | return $this->kernel->handle($request); |
60 | } |
61 | |
62 | // Don't change anything else |
63 | |
64 | } |
Now we can create a custom module that extends the Laravel4 module and add some new functionality:
1 | namespace Codeception\Module; |
2 | |
3 | use CustomLaravel4Connector; |
4 | |
5 | class CustomLaravel4 extends \Codeception\Module\Laravel4 |
6 | { |
7 | /** |
8 | * Initialize hook. |
9 | */ |
10 | public function _initialize() |
11 | { |
12 | $this->checkStartFileExists(); |
13 | $this->registerAutoloaders(); |
14 | $this->revertErrorHandler(); |
15 | $this->client = new CustomeLaravel4Connector($this); |
16 | } |
17 | |
18 | /** |
19 | * Allow the Codeception Actor to add a binding to the Laravel IOC |
20 | * |
21 | * @return \Illuminate\Foundation\Application |
22 | */ |
23 | public function bindService($abstract, $instance, $shared = false) |
24 | { |
25 | $this->app->bind($abstract, $instance, $shared = false); |
26 | } |
27 | |
28 | /** |
29 | * Allow the Codeception Actor to bind an instantiated object to the Laravel IOC |
30 | * |
31 | * @return \Illuminate\Foundation\Application |
32 | */ |
33 | public function bindInstance($abstract, $instance) |
34 | { |
35 | $this->app->instance($abstract, $instance); |
36 | } |
37 | } |
Presto! That is all there is to it. Now your mock objects can be bound appropriately and will remain in place for the duration of the test. Our new acceptance test looks like this:
1 | $I = new FunctionalTester($scenario); |
2 | |
3 | $I->wantTo('upgrade a subscription plan'); |
4 | |
5 | $I->amActingAs('[email protected]'); // This is a custom helper method which sets the active user |
6 | |
7 | // Establish the necessary mocks |
8 | $I->bindService('Acme\Billing\BillingInterface', function(){ |
9 | return Mockery::mock('Acme\Billing\StripeBillingManager'); |
10 | }); |
11 | |
12 | $I->amOnRoute('billing.upgrade.form'); |
13 | |
14 | $I->submitForm('subscription-form', [ |
15 | 'plan' => 'premium', |
16 | 'token' => csrf_token() |
17 | ]); |
18 | |
19 | $I->seeInDatabase('users', ['email' => '[email protected]', 'plan' => 'premium'); |
I have added my custom module and connector to my Laravel-Testing-Utilities package if you would like to make use of them in your own Laravel 4.2 application. Currently they are only on the 1.* branch, but if needed I will add them to the other versions of that package down the road.