philbates's avatar

How can I acceptance test when the service provider bindings are dynamic?

I have a multi-tenant app used by many clients. Lets say the app has an optional "blog" module that can either be enabled or disabled. For some clients it's enabled, for some its disabled. If it's enabled, there is another posts_per_page setting in the same config file. I see two different ways of handling this:

  • A settings table that would somehow contain the fact that blog is either enabled disabled (either serialised JSON, or in over two columns setting, and value) and how many posts per page there should be. This would be set via some sort of admin panel in the UI in the application.
  • A default Laravel config file config/blog.php containing a setting enabled config in which is false by default. I detect the client by an environment variable called CLIENT, which results in all config from clients/<CLIENT>/config/blog.php overriding the main config during application bootstrap. So for the clients that should have the blog module enabled

I've gone ahead and implemented the second option (config driven), but in either case, what I wanted to do is only use these config values in the BlogServiceProvider to set up the necessary bindings. Consider this (highly simplified) example:

// config/blog.php
return [
    'enabled' => false,
    'posts_per_page' => 10,
];

// clients/client_a/config/blog.php
return [
    'enabled' => true, // Client A has overriden the default config to enable the module.
    // They didn't override `posts_per_page`, so the default value will be used
];

// app/Blog/BlogPostListings.php
class BlogPostListings
{
    private $perPage;

    public function __construct($perPage)
    {
        $this->perPage = $perPage;
    }

    public function displayPage()
    {
    return Blog::paginate($this->perPage);
    }
}

// app/Providers/BlogServiceProvider.php
public function register()
{
    // If the module isn't enabled, then don't register anything
    if (!$this->app->['config']->get('blog.enabled')) {
        return false;
    }

    // Register services, injecting values from the config
    $this->app->bind('App\Blog\BlogPostListings', function($app) {
        return new \App\Blog\BlogPostListings($app['config']->get('blog.posts_per_page'));
    });

    // Register the routes required for the blog module to work
    $this->app->register('App\Providers\Blog\RouteServiceProvider');
}

I've tried this out, and it works perfectly. The issue I have is when trying to acceptance test the blog module. In the simplest form, I'd like something like the following three tests:

  1. "Given that the blog modules is enabled and posts_per_page is 15, when I got to /blog I see no more than 15 items.
  2. "Given that the blog module is disabled, when I go to /blog I get a 404.

However, this isn't really possible in either PhpUnit or Behat, but both for the same reason. To demonstrate what I mean:

// tests/Blog/BlogTest.php
public function testISeeTheCorrectNumberOfBlogPosts()
{
    // As soon as we enter the test, the BlogServiceProvider has already
    // been registered using the default config (which is that the blog
    // module is disabled). So setting the config here doesn't make a
    // difference, the blog service provider bindings and routes won't
    // be registered
    
    config(['blog.enabled' => true]); // Happens after BlogServiceProvider is registered

    $this->visit('/blog')->see('Blog'); // Fails, because blog routes were never registered
}

Given that this isn't possible, I was wondering is something is fundamentally wrong with this architecture? Do you have any thoughts on a different way that I could implement an optional module system, while keeping all module code in the same code base, in a way that can be acceptance tested?

If the architecture is sound, an alternative is to have a set of acceptance tests per client (using their personal config overrides). This would work - however, we'd end up with so many duplicated acceptance tests - if 20 clients have the blog module enabled, but all with different posts_per_page, then I'd have to duplicate that test 20 times (once for each client), but modified slightly for their value of posts_per_page. For the remaining clients that have it disabled, I'd need to test that when they visit /blog they get a 404. Although in principle this is a nice idea, the amount of duplication concerns me. I'm also not sure how this option would work if instead of using config files I was instead using a database driven configuration (case 1. at the top of this post).

Any thoughts?

0 likes
4 replies
bobbybouwmann's avatar

PHPUnit has a setUp method. You should do those things in there. Set the correct app and configs and so on

philbates's avatar

I can't use setUp either. There's two cases - I either try and set the config before calling the parent setUp or after.

If I try before:

// tests/Blog/BlogTest.php
public function setUp()
{
    config(['blog.enabled' => true]); // Error - application hasn't loaded yet

    parent::setUp(); // This is where the application loads
}

If I try after:

// tests/Blog/BlogTest.php
public function setUp()
{
    parent::setUp(); // This is where the application loads

    // BlogServiceProvider has already been registered using default config, so this
   // line essentially has no effect and the blog remains disabled.
    config(['blog.enabled' => true]);    
}

Even if it was possibly to do this in the set up method, there is only one setUp method per test file - I'd really like to have the ability to set the config on a per-test-method basis.

philbates's avatar

Nope, that won't work either! Because at that point config hasn't yet been bound into the application. After $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); not only has config been bound, but all service providers have been registered so by that point its too let to set custom config.

Anyway, the more I've been thinking about it, the more I'm leaning to a set of acceptance tests for each client. I think that makes the most sense.

Please or to participate in this conversation.