I am a programmer and architect (the kind that writes code) with a focus on testing and open source; I maintain the PHPUnit_Selenium project. I believe programming is one of the hardest and most beautiful jobs in the world. Giorgio is a DZone MVB and is not an employee of DZone and has posted 638 posts at DZone. You can read more from them at their website. View Full User Profile

Open/Closed Principle on real world code

01.12.2012
| 7098 views |
  • submit to reddit
This article shows an example of how the application of the Open/Closed Principle improved the design of a real project, the open source library PHPUnit_Selenium. These design concepts apply to every object-oriented language, including Java, Ruby or even C++.

Some theory

The Open Closed Principle, part of SOLID set, states that software should be open for extension and at the same time closed for modification. Implementations of this principle in OO languages usually use inheritance from interfaces or classes to support the addition of new features via the addition of new classes.

Since the alternative to the addition of classes is the modification of existing code, OCP leads us to touching the existing application as less as possible. You cannot break things if you do not modify them.

Background for this example

PHPUnit_Selenium has a Session object representing a browser opened by Selenium and that can be used to perform tests. There are many commands to support, from title that retrieves the <title> of the page, to url which may be called with or without arguments (for accessing or mutating the current location)

There are details related to each command: since the PHP process communicates with Selenium with a REST-like API, it may have to use a POST or GET request, depending on the command type. And the parameters may be processed differently:

  • sometimes you have to pass a complex array with options.
  • sometimes a single argument, but Selenium accepts it only as a complex array. For example, url must be specified as an array with a single element: array('url' => ...). A set of characters to type is even more difficult to manage, as a string like 'Hi' has to be posted as array('H', 'i').

Before the application of OCP

To avoid writing a full method for each new command to support, they are supported with __call() as magic methods on the Session object (this would be equivalent to a method callCommand($commandName, ...)):

    public function __call($command, $arguments)
    {
        if (count($arguments) == 1) {
            if (is_string($arguments[0])) {
                $jsonParameters = array('url' => $this->baseUrl->addCommand($arguments[0])->getValue());
            } else if (is_array($arguments[0])) {
                $jsonParameters = $arguments[0];
            } else {
                throw new Exception("The argument should be an associative array or a single string.");
            }
            $response = $this->curl('POST', $this->sessionUrl->addCommand($command), $jsonParameters);
        } else if (count($arguments) == 0) {
            $response = $this->curl($this->preferredHttpMethod($command),
                                    $this->sessionUrl->addCommand($command));
        } else {
            throw new Exception('You cannot call a command with multiple method arguments.');
        }
        return $response->getValue();
    }

However this implementation is a mess:

  • there are several branches that depend on the number of arguments: 0 means a GET command, while at least one argument (a complex array) results in a POST one.
  • other branches depend on what is the command: url is special and should wrap its only parameter into an array.

In general, this solution doesn't scale to add more commands, as the __call() method will grow to hundreds of lines. Every time a new command is added, it would gain another branch and maybe break the previous commands cases: even with a test suite in place, I'd rather avoid regressions, if only for the time they take to be fixed.

After the application of OCP

The Session class now lists the available commands as an array of methods that can create a Command object:

    public function __construct(...)
    {
        $this->commandFactories = array(
            'acceptAlert' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_AcceptAlert'),
            'alertText' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_GenericAccessor'),
            'dismissAlert' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_DismissAlert'),
            'title' => $this->factoryMethod('PHPUnit_Extensions_Selenium2TestCase_SessionCommand_GenericAccessor'),
            'url' => function ($jsonParameters, $commandUrl) use ($baseUrl) {
                return new PHPUnit_Extensions_Selenium2TestCase_SessionCommand_Url($jsonParameters, $commandUrl, $baseUrl);
            }
        );
    }

    /**
     * @params string $commandClass     a class name, descending from
                                        PHPUnit_Extensions_Selenium2TestCase_Command
     * @return callable
     */
    private function factoryMethod($commandClass)
    {
        return function($jsonParameters, $url) use ($commandClass) {
            return new $commandClass($jsonParameters, $url);
        };
    }

    public function __call($commandName, $arguments)
    {
        $jsonParameters = $this->extractJsonParameters($arguments);
        $response = $this->driver->execute($this->newCommand($commandName, $jsonParameters));
        return $response->getValue();
    }

    /**
     * @return string
     */
    private function newCommand($commandName, $arguments)
    {
        if (isset($this->commandFactories[$commandName])) {
            $factoryMethod = $this->commandFactories[$commandName];
            $commandUrl = $this->sessionUrl->addCommand($commandName);
            $commandObject = $factoryMethod($arguments, $commandUrl);
            return $commandObject;
        }
        throw new BadMethodCallException("The command '$commandName' is not existent or not supported.");
    }

$this->commandFactories is an array of anonymous functions indexed by command name. Each of these functions can create the relevant command object with two parameters: the $jsonParameters containing configuration for Selenium, and the command URL which is used as a target for execution (making an HTTP request to /session/123/title).

This fields can be injected, or substituted with a CommandFactory object to outsource completely the command list concern.

Initially all these anonymous Factory Methods were identical, with only the class name changing. However, the SessionCommand_Url class has an additional parameter (the base url of the website we're on) and so I felt including the more general solution in this article would be more complete. It's obvious that as more and more commands are added it becomes more likely that some of them require different arguments, and so the Command object creation cannot be identical for all cases.

The base Command class is extended by all Command objects. An interface would be less coupled, and I will go for that in the case third-party Command objects have to be supported.

abstract class PHPUnit_Extensions_Selenium2TestCase_Command
{
    protected $jsonParameters;
    private $commandName;

    /**
     * @param array $jsonParameters     null in case of no parameters
     */
    public function __construct($jsonParameters,
                                PHPUnit_Extensions_Selenium2TestCase_URL $url)
    {
        $this->jsonParameters = $jsonParameters;
        $this->url = $url;
    }

    public function url()
    {
        return $this->url;
    }

    /**
     * @return string
     */
    abstract public function httpMethod();

    /**
     * @param array $jsonParameters     null in case of no parameters
     */
    public function jsonParameters()
    {
        return $this->jsonParameters;
    }
}

Note that, at least initially, an abstract class is more flexible as it allows to add methods to all the Command objects in a single place.

Here are some examples of Command classes: the first is the command for accepting an alert box by clicking on Ok.

class PHPUnit_Extensions_Selenium2TestCase_SessionCommand_AcceptAlert
    extends PHPUnit_Extensions_Selenium2TestCase_Command
{
    public function httpMethod()
    {
        return 'POST';
    }
}

There is also the command for modifying the current location, or retrieve it after a redirect or a submit:

class PHPUnit_Extensions_Selenium2TestCase_SessionCommand_Url
    extends PHPUnit_Extensions_Selenium2TestCase_Command
{
    public function __construct($relativeUrl, $commandUrl, $baseUrl)
    {
        if ($relativeUrl !== NULL) {
            $absoluteLocation = $baseUrl->addCommand($relativeUrl)->getValue();
            $jsonParameters = array('url' => $absoluteLocation);
        } else {
            $jsonParameters = NULL;
        }
        parent::__construct($jsonParameters, $commandUrl);
    }

    public function httpMethod()
    {
        if ($this->jsonParameters) {
            return 'POST';
        }
        return 'GET';
    }
}

Conclusion

The code is not a mess anymore: adding a command means writing a separate new class, and adding a single line in Session in the list of Factory Methods.

Some conditionals are still there, for example to decide when a command should use POST or GET; however, they are confined in the environment of a single command and this simplifies them a lot (only a single branch is needed.)

Finally, remember that while applying this version of the Command pattern, you can usually start by having just a list of class names to instantiate; after a while you can add indirection to allow for a clean creation of them (in my case having the Url command being passed the additional parameter instead of pulling it from some singleton).

All in all, you can add IFs until a class explodes, or you can extract some interface or abstract class to manage new features with small, brand new objects.

Published at DZone with permission of Giorgio Sironi, author and DZone MVB.

(Note: Opinions expressed in this article and its replies are the opinions of their respective authors and not those of DZone, Inc.)