Skip to content

Instantly share code, notes, and snippets.

@mmoreram
Last active August 29, 2015 14:08
Show Gist options
  • Save mmoreram/332f2aa374d379fe3c6a to your computer and use it in GitHub Desktop.
Save mmoreram/332f2aa374d379fe3c6a to your computer and use it in GitHub Desktop.
Dependency Injection and TDD

La mayoría de los proyectos ya existen. ¿Que quiero decir con esto? Bueno, creo que la mayoría de desarrolladores en la era de las grandes documentaciones simplemente abusan de la superficialidad que estas nos permiten. Y creo que esto puede estar bien en cierto modo, pero también creo que son las grandes implementaciones las que, en última instancia, nos hacen crecer como profesionales.

Es por esto que en la documentación del proyecto que quiero pseudodocumentar hoy empieza con una de las frases más conocidas por el homo-developer. "Oh, no way... another Dependency Injection container written in PHP?". Pues me temo que si, y siento defraudar a la gente que espere que este DIC tenga más features, y más chulas o modernas que las ya existentes. This is not gonna happen.

Lo que si prometo es conocimiento mediante implementación, uno de los grandes recursos que tenemos como profesionales y una de las técnicas más desaprovechadas de nuestro entorno. Quiero explicaros la forma en la que he planteado este ejercicio, y como el TDD me ha ayudado tanto en la definición como en la implementación.

Empezemos pues. Que es lo primero que debemos hacer para plantear un proyecto? Algunos de vosotros tal vez respondáis sin pensar demasiado... "programar!". Bueno, porque no... ¿pero programar el que? Parece imposible programar nada sin tener una pequeña idea de lo que uno quiere programar, almenos tener una pequeña aproximación, un pequeño análisis o simplemente un segundo de inspiración fugaz.

Pues bien, en esta ocasión, vamos a dedicar un tiempo consistente a esta fase de análisis, y vamos a pensar exactamente la forma en que un usuario de nuestra aplicación debería poder interactuar con nuestra aplicación. Y dado que nuestro proyecto es un Dependency Injection Container, vamos a definir toda posible interacción exterior.

En este post, voy a dar por supuesto que tienes conciencia de estos conceptos.

Así que propongo empezar a definir lo que nuestro container podrá handle, definiendo como se comportará dado una entrada y un environment, así como los estados que este experimentará durante todo el proceso. Me explico.

  • Hombre... un DIC? Que puedo hacer con él?
  • Pues no mucho, algunas cosas básicas.
  • ¿Cuales?
  • Enumero:
    • Vamos a trabajar con el concepto de Parameter y de Service.
    • En nuestro caso, un parámetro no es otra cosa que un valor asignado a un identificador único. Este valor puede ser cualquier tipo de elemento asignable en PHP (entero, string, object, callable...)
    • En nuestro caso, un servicio es una instancia de un objeto, definido por un identificador único, su namespace y un array de argumentos
    • Cada uno de estos argumentos puede ser una referencia a otro servicio, siguiendo el formato "@service_id", un parámetro siguiendo el formato "~parameter_id" o en su defecto, un valor cualquiera.
    • Dado que es un contenedor, un servicio será instanciado una (y solo una vez) devolviendo siempre la misma instancia. Dicho servicio será instanciado en el momento que se pida por primera vez.
    • Para adquirir este servicio, se utilizará el método público $container->get("service_id")
    • Para adquirir un parámetro, se utilizará el método público $container->getParameter("parameter_name");
  • Bien.

¿Que os parece como definición? Bueno, pues creo que es un buen comienzo para empezar nuestra implementación.

  • ¿Ya implementamos?

Bueno, si, pero no vamos a implementar el container en sí, sino vamos a crear un pequeño juego de pruebas con toda esta definición que hemos generado. Tenemos suficiente información para poder definir en que casos nuestra aplicación debería funcionar y los elementos de retorno que debería devolver, y en que ocasiones debería fallar.

Vamos a verlo por partes. Empezemos por un ejemplo de una correcta configuración de nuestros servicios.

$configuration = [
    'my_service' => [
        'class' => 'My\Class\Namespace',
        'arguments' => [
            '@my_other_service',
            '~my_parameter',
            'simple_value',
        ]
    ],
    'my_other_service' => [
        'class' => 'My\Class\Namespace',
    ],
    'my_parameter' => 'parameter_value',
];

Como podéis observar tenemos:

  • Un servicio con identificador my_service, cuya instancia se resuelve con el namespace My\Class\Namespace y pasando por argumentos otro servicio llamado my_other_service, un parámetro llamado my_parameter y un string simple con valor "simple_value"
  • Un servicio con identificador my_other_service (referenciado en el constructor del servicio anterior), cuya instancia se crea haciendo un simple new de la clase My\Other\Class\Namespace, sin dependencias.
  • Un parámetro con identificador my_parameter, cuyo valor es el string "parameter_value"

Partiendo de la base que existen ambas clases con sus constructores adecuados, en principio esta configuración es coherente, por lo que nuestro container debería poder compilarlo perfectamente.

Compilar el container en nuestro caso no es otra cosa que, dada una
configuración, hacer check que tal configuración es coherente, y crear una
estructura interna para poder instanciar los objetos más rapidamente.
Luego vamos a ver como funciona.

Veamos una configuración que no debería funciona por varias razones.

$configuration = [
    '' => [
        'class' => 'My\Non\Existing\Class\Namespace',
        'arguments' => [
            '@my_non_existing_service',
            '~my_non_existing_parameter',
            'simple_value',
        ]
    ],
];

En este caso

  • El identificador del servicio es incorrecto, no puede ser vacío
  • no existe el namespace My\Non\Existing\Class\Namespace
  • No existe el servicio my_non_existing_service
  • No existe el parámetro my_non_existing_parameter

Vamos viendo que poco a poco estamos definiendo bastante bien como se debe comportar nuestra librería, aún no habiendo implementado absolutamente nada. Pues a pesar que parezca algo raro, en realidad es algo muy útil, ya que puede ahorrarte mucho tiempo de refáctoring a posteriori, justamente por el mero hecho de no haber prestado atención a la definición.

La segunda parte de esta fase, cuando tenemos un juego de pruebas bien definido, tanto los que harán que nuestra aplicación funcione bien, como los que harán que no funcione, es cuando empezamos nuestros tests unitarios. En nuestro caso vamos a trabajar con la librería PHPUnit por mi conocimiento de la propia librería y por el hecho que es una libería bastante extendida y de sobras conocida y testeada.

Veamos una pequeña muestra de nuestro primer test.

/**
 * This method just will test that, given a configuration, the container will
 * be built properly
 */
public function testBuildOk()
{
    $configuration = [
        'my_service' => [
            'class' => 'My\Class\Namespace',
            'arguments' => [
                '@my_other_service',
                '~my_parameter',
                'simple_value',
            ]
        ],
        'my_other_service' => [
            'class' => 'My\Class\Namespace',
        ],
        'my_parameter' => 'parameter_value',
    ];
    $container = new Doppo($configuration);
    $container->compile();
}

En si, este test no hace ningun assert, simplemente testea que la librería no lanzará ningún tipo de exception, por lo que debería ser suficiente. Evidentemente, debemos testear todas las possibles configuraciones distintas, por lo que podríamos convertir el test anterior por algo como este.

/**
 * This method just will test that, given a configuration, the container will
 * be built properly
 *
 * @dataProvider dataBuildOk
 */
public function testBuildOk(array $configuration)
{
    $container = new Doppo($configuration);
    $container->compile();
}

/**
 * data for testBuildOk
 */
public function dataBuildOk()
{
    return [
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_other_service' => [
                'class' => 'My\Class\Namespace',
            ],
            'my_parameter' => 'parameter_value',
        ]],
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => []
            ],
        ]],
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
            ],
        ]],
    ];
}

Bonita batería de tests, no? En este caso, para cada una de las posiciones del array que devuelve el método dataBuildOk, testearemos la compilación de nuestro. Aqui podéis encontrar información sobre la annotation @dataProvider de PHPUnit.

Vamos a intentar definir casos en los que nuestro contenedor debería fallar. Para esto implementaremos una capa de fallo muy básica, formada solo por el lanzado de Exception en todos los puntos de fallo.

/**
 * This method just will test that, given a configuration, the container will
 * be built properly
 *
 * @dataProvider dataBuildFail
 * @exceptionExpected \Exception
 */
public function testBuildFail(array $configuration)
{
    $container = new Doppo($configuration);
    $container->compile();
}

/**
 * data for testBuildFail
 */
public function dataBuildFail()
{
    return [
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_parameter' => 'parameter_value',
        ]],
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_other_service' => [
                'class' => 'My\Class\Namespace',
            ],
        ]],
        [[
            'my_service' => [
                'class' => 'My\Non\Existing\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_other_service' => [
                'class' => 'My\Class\Namespace',
            ],
            'my_parameter' => 'parameter_value',
        ]],
        [[
            '' => [
                'class' => 'My\Class\Namespace',
            ],
        ]]
    ];
}

En este caso, si os fijáis, estamos cubriendo todos los posibles casos que habíamos definido anteriormente, por lo que en el momento en que los tests pasen, el comportamiento del container será el esperado.

Estamos haciendo tests, recordáis? Siempre que se hace tests, debemos lanzarlos para saber el resultado de su ejecución. Para que nos hagamos una pequeña idea, cuanto más verde sea la pantalla de resultados, mejor.

Dado que ahora mismo no tenemos ninguna implementación, evidentemente los tests fallarán, todos, pero debemos contar con ello ya que forma parte de la metodología TDD en sí.

El trabajo en este punto es conseguir que, de forma iterativa, vayamos implementando aquello que vamos especificando y testeando, por lo que dada toda la especificación que hemos logrado hasta este punto con los tests, vamos a empezar nuestra implementación.

/*
 * This file is part of the Doppo package
 */
class Doppo
{
    /**
     * @var array
     * 
     * Configuration
     */
    private $configuration;

    /**
     * Constructor
     *
     * @param array   $configuration Container Configuration
     * @param boolean $debug         Debug mode
     */
    public function __construct(array $configuration)
    {
        $this->configuration = $configuration;
    }

    /**
     * Compile action
     */
    public function compile() {

    }

    /**
     * Get service instance given its name
     * 
     * @param string $serviceName Service name
     * 
     * @return Object service instance
     */
    public function get($serviceName)
    {

    }

    /**
     * Get parameter value given its name
     * 
     * @param string $parameterName Parameter name
     * 
     * @return mixed parameter value
     */
    public function getParameter($parameterName)
    {

    }
}

Esta es nuestra clase completamente vacía. Si ahora hacemos correr nuestros tests probablemente los errores no sean que no los métodos no existen, sino que los resultados no serán los esperados. Para la implementación vamos a seguir unos pasos para que no nos perdemos. Iremos buscando pequeños hitos para tener, al fin, toda la implementación completa y nuestros tests en verde.

Implementando el compilador

Vamos a separar en distintos bloques la implementación de nuestro compilador.

  • Detección de tipo (parámetro o servicio)
  • Compilación de parámetros
    • Estructura interna - ParameterDefinition
  • Compilación de servicios
    • Valors por defecto
    • Estructura interna - ServiceDefinition
    • Argumentos de servicio, ArgumentChain
      • ParameterArgument
      • ServiceArgument
  • Compilation check

Detección de tipo

Para crear una nueva instancia de compilador, necesitamos proveer un array de configuración como el que hemos visto anteriormente.

Este array puede contener tanto la definición de un servicio como la definición de un parámetro, por lo que de alguna forma debemos comprobar de cual se trata.

Para esto, y dado que la unica propiedad indispensable que tiene un servicio que no tiene un parámetro es la clase que lo define, es comprobar si este valor está en la definición. En caso afirmativo, compilaremos el bloque como servicio. Otherwise lo haremos como parámetro.

/**
 * Compile the configuration
 *
 * @param array $configuration Container Configuration
 *
 * @throws Exception Element type is not correct
 */
protected function compileConfiguration(array $configuration)
{
    foreach ($configuration as $configurationName => $configurationElement) {
        
        if (is_array($configurationElement) && array_key_exists('class', $configurationElement)) {
            
            $this->compileService(
                $configurationName,
                $configurationElement
            );
        } else {
            
            $this->compileParameter(
                $configurationName,
                $configurationElement
            );
        }
    }
}

En este caso, vemos que iteramos todas las posiciones del array, y para cada una de ellas hacemos dicha comprobación. En el caso que encontremos class compilamos un servicio, y sino, un parámetro.

Compilación de parámetro

Para compilar nuestros parámetros vamos a trabajar con una estructura propia. Esta estructura consta de un Chain, algo parecido a una collection, y de un ValueObject con la información necesaria para definir un parámetro.

Podéis ver las implementaciones aqui.

Dado que vamos a popular una instancia del tipo ParameterDefinitionChain, debemos inicializarlo en el constructor de nuestro compilador.

/**
 * Constructor
 *
 * @param array   $configuration Container Configuration
 * @param boolean $debug         Debug mode
 */
public function __construct(array $configuration)
{
    $this->configuration = $configuration;
    $this->parameters = new ParameterDefinitionChain();
}

Para compilar un parámetro, solo necesitamos popular dicha colección con objetos del tipo ParameterDefinition. A continuación una pequeña implementación.

/**
 * Compile a parameter
 *
 * @param string $parameterName  Parameter name
 * @param string $parameterValue Parameter value
 */
protected function compileParameter($parameterName, $parameterValue)
{
    $this
        ->parameters
        ->addParameterDefinition(
            new ParameterDefinition(
                $parameterName,
                $parameterValue
            )
        );
}

Compilación de servicio

La complejidad de compilar un servicio es un poco más alta. Como hemos visto anteriormente hay algunos escenarios en que nuestra definición no es buena, por lo que tenemos que buscar estos casos y lanzar una excepción (En nuestro caso, siempre una \Exception).

Como en los parámetros, vamos a trabajar con una estructura interna para definir lo que es un servicio.

Podéis ver las implementaciones aqui.

Como anteriormente hemos hecho con el objeto ParameterDefinitionChain, vamos a inicializar la instancia del compilador en su constructor.

/**
 * Constructor
 *
 * @param array   $configuration Container Configuration
 * @param boolean $debug         Debug mode
 */
public function __construct(array $configuration)
{
    $this->configuration = $configuration;
    $this->parameters = new ParameterDefinitionChain();
    $this->services = new ServiceDefinitionChain();
}

Una vez hemos inicializado la clase Chain, debemos implementar como se construye una instancia del tipo ServiceDefinition dado un array de definición. Teniendo en cuenta que:

  • Un servicio sin class definida no puede existir
  • Cuando un servicio no tiene argumentos definidos, equivale a constructor vacío

Podemos implementar algo parecido a esto (La parte de los argumentos aún no la hemos trabajado, es un paso posterior).

/**
 * Compile a service
 *
 * @param string $serviceName          Service name
 * @param array  $serviceConfiguration Service configuration
 *
 * @throws DoppoServiceClassNotFoundException Service class not found
 */
protected function compileService($serviceName, array $serviceConfiguration)
{
    if (!class_exists($serviceConfiguration['class'])) {
        throw new DoppoServiceClassNotFoundException(
            sprintf(
                'Class %s not found',
                $serviceConfiguration['class']
            )
        );
    }
    $arguments = isset($serviceConfiguration['arguments'])
        ? $serviceConfiguration['arguments']
        : array();

    $this
        ->services
        ->addServiceDefinition(
            new ServiceDefinition(
                $serviceName,
                '\\' . ltrim($serviceConfiguration['class'], '\\'),
                $this->compileArguments($arguments),
                $public
            )
        );
}

En realidad este bloque no es demasiado distinto al de los parámetros, pero claro, el tema es que un servicio puede tener argumentos, y es una parte que también debemos tener en cuenta a la hora de compilar.

Argumentos

Para los argumentos hemos creado una pequeña estructura parecida a la que estamos utilizando para compilar tanto servicios como parámetros.

ArgumentChain ServiceArgument ParameterArgument ValueArgument

Esta estructura se resume en que un objeto del tipo ArgumentChain contiene n elementos del tipo Argument, una Interface de la cual extienden tanto ServiceArgument, ParameterArgument y ValueArgument.

A la hora de compilar los argumentos, tenemos este método, encargado de popular el object ArgumentChain.

/**
 * Compile arguments
 *
 * @param array $arguments Argument configuration
 *
 * @return ArgumentChain Argument chain
 */
protected function compileArguments(array $arguments)
{
    $argumentChain = new ArgumentChain();
    foreach ($arguments as $argument) {
        $argumentChain->addArgument(
            $this->compileArgument($argument)
        );
    }

    return $argumentChain;
}

pero ahora estamos en la misma posición que antes. Debemos saber que tipo de argumento se trata en cada caso.

Como hemos definido anteriormente:

  • Cuando el argumento empieza por el carácter @, se trata de una referencia a un servicio
  • Cuando el argumento empieza por el carácter ~, se trata de una referencia a un parámetro
  • Sino, se trata de un valor plano

Nuestra implementación, entonces, buscará estos carácteres, y en función del formato, compilará un argumento del tipo servicio, parámetro o valor.

/**
 * Given an argument return its definition
 *
 * @param string $argument Argument
 *
 * @return Argument Argument
 */
protected function compileArgument($argument)
{
    $argumentDefinition = null;

    if (is_string($argument) && strpos($argument, Doppo::SERVICE_PREFIX) === 0) {
        
        $cleanArgument = preg_replace('#^' . Doppo::SERVICE_PREFIX . '{1}#', '', $argument);
        $argumentDefinition = $this->compileServiceArgument($cleanArgument);
    } elseif (is_string($argument) && strpos($argument, Doppo::PARAMETER_PREFIX) === 0) {
        
        $cleanArgument = preg_replace('#^' . Doppo::PARAMETER_PREFIX . '{1}#', '', $argument);
        $argumentDefinition = $this->compileParameterArgument($cleanArgument);
    } else {
        
        $argumentDefinition = $this->compileValueArgument($argument);
    }

    return $argumentDefinition;
}
/**
 * Given a service argument value return its definition
 *
 * @param string $argumentValue Argument value
 *
 * @return ServiceArgument Service argument
 */
protected function compileServiceArgument($argumentValue)
{
    return new ServiceArgument($argumentValue);
}
/**
 * Given a parameter argument value return its definition
 *
 * @param string $argumentValue Argument value
 *
 * @return ParameterArgument Parameter argument
 */
protected function compileParameterArgument($argumentValue)
{
    return new ParameterArgument($argumentValue);
}
/**
 * Given a value argument value return its definition
 *
 * @param mixed $argumentValue Argument value
 *
 * @return ValueArgument Value argument
 */
protected function compileValueArgument($argumentValue)
{
    return new ValueArgument($argumentValue);
}

A la hora de compilar los argumentos, nos damos cuenta que, y dado que es posible que estemos creando una referencia a un servicio que aún no está compilado, tenemos que revisar posteriormente que todas las referencias son correctas.

Es por esto que, en el punto en que hemos compilado la configuración entera, debemos añadir una última capa para comprobar todos los argumentos.

/**
 * Check services arguments references
 *
 * This call has only sense if the service stack is built before. The why
 * of this methods is because now we have the correct acknowledgement about
 * all the services and parameters we will work with.
 *
 * We will now check that all service arguments have correct references.
 *
 * @throws DoppoServiceArgumentNotExistsException service argument not found
 */
protected function checkServiceArgumentsReferences()
{
    $this
        ->services
        ->each(function (ServiceDefinition $serviceDefinition) {

            $serviceDefinition
                ->getArgumentChain()
                ->each(function (Argument $argument) use ($serviceDefinition) {

                    $argumentValue = $argument->getValue();
                    if ($argument instanceof ServiceArgument) {
                        if (!$this->services->has($argumentValue)) {

                            throw new DoppoServiceArgumentNotExistsException(
                                sprintf(
                                    'Service "%s" not found in "@%s" arguments list',
                                    $argumentValue,
                                    $serviceDefinition->getName()
                                )
                            );
                        }
                    }

                    if ($argument instanceof ParameterArgument) {
                        if (!$this->parameters->has($argumentValue)) {

                            throw new DoppoServiceArgumentNotExistsException(
                                sprintf(
                                    'Parameter "%s" not found in "@%s" arguments list',
                                    $argumentValue,
                                    $serviceDefinition->getName()
                                )
                            );
                        }
                    }
                });
        });
}

Simple. Si un argumento del tipo ServiceArgument contiene una referencia a un servicio inexistente, lanzamos una Exception. Por otro lado, si tenemos un argumento del tipo ParameterArgument con una referencia a un parámetro inexistente, también lanzamos una Exception.

Hasta aqui todo el proceso de compilación. El resultado final de compilar un container con una configuración válida es un objeto del tipo ParameterChain con instancias de ParameterDefinition, y un objeto del tipo ServiceChain con instancias de ServiceDefinition.

Recuperemos los tests que hemos hecho anteriormente. Si ahora los intentamos pasar veremos que efectivamente pasarán en verde, por lo que nuestra implementación, por el momento, se estará comportando como queremos.

Sigamos.

La segunda iteración la vamos a dedicar al hecho que un container se puede, o debería poderse compilar una sola vez. Una vez compilado, solo se debería poder acceder a las instancias de los servicios y a los valores de los parametros.

Para esto, vamos a crear un pequeño test que nos permita comprobar este escenario.

/**
 * Test container compilation more than once
 *
 * @expectedException \Exception
 */
public function testCompileMoreThanOnce()
{
    $container = new Doppo(array());
    $container->compile();
    $container->compile();
}

En este caso vemos que no nos importa el contenido de la configuración. En el momento en que compilemos un container compilado, esperamos que se lanze una Exception. Si lanzamos nuestro test ahora, volverá a fallar, por lo que necesitamos implementar tal feature hasta que no falle.

Para esto, podemos reforzar nuestra clase con estos elementos

/**
 * @var boolean
 *
 * The container is compiled
 */
protected $compiled;

/**
 * Constructor
 *
 * @param array   $configuration Container Configuration
 * @param boolean $debug         Debug mode
 */
public function __construct(array $configuration)
{
    // Initialization

    $this->compiled = false;
}

/**
 * Compile container
 *
 * @throws Exception Container already compiled
 */
public function compile()
{
    if (true === $this->compiled) {
        
        throw new DoppoAlreadyCompiledException(
            'Container already compiled'
        );
    }

    $this->compileConfiguration($this->configuration);
    $this->checkServiceArgumentsReferences();
    $this->compiled = true;
}

Pasemos ahora los tests. Todo en verde! Bien, ahora tan solo nos queda implementar como instanciamos y devolvemos los servicios y los parámetros.

Services instances

Tal y como hemos definido al inicio del post, una de las especificaciones de nuestro container es que para recuperar un servicio debemos hacerlo con el método $container->get($serviceName);.

Por ahora esta funcionalidad no la hemos implementado, por lo que empezamos con los tests dando por supuesto que ninguno de ellos va a pasar.

/**
 * Testing get method with good values
 */
public function testGetOK()
{
    $container = new Doppo(array(
        'foo'          => array(
            'class'     => '\Doppo\Tests\Data\Foo',
            'arguments' => array(
                'value1',
                array('value2'),
                '~my.parameter',
            ),
        ),
        'goo'          => array(
            'class'     => 'Doppo\Tests\Data\Goo',
            'arguments' => array(
                '@foo',
                '@moo',
            ),
        ),
        'moo'          => array(
            'class' => 'Doppo\Tests\Data\Moo'
        ),
        'my.parameter' => 'my.value',
    ));

    $container->compile();
    $this->assertInstanceOf('Doppo\Tests\Data\Foo', $container->get('foo'));
    $this->assertInstanceOf('Doppo\Tests\Data\Goo', $container->get('goo'));
    $this->assertInstanceOf('Doppo\Tests\Data\Moo', $container->get('moo'));
}

/**
 * Testing get method in a non-compiled Container
 *
 * @expectedException \Exception
 */
public function testGetFail()
{
    $doppo = $this->getDoppoInstance(array(
        'moo' => array(
            'class' => 'Doppo\Tests\Data\Moo'
        ),
    ));
    $doppo->get('moo');
}

/**
 * Testing get method with bad values
 *
 * @expectedException \Exception
 */
public function testGetFail()
{
    $container = $this->getDoppoInstance(array(
        'moo' => array(
            'class' => 'Doppo\Tests\Data\Moo'
        ),
    ));
    $container->compile();
    $container->get('foo');
}

/**
 * Tests that a service is only built once, even is called more than once
 */
public function testServiceInstancedOnce()
{
    $container = $this
        ->getMockBuilder('Doppo')
        ->setMethods[array('buildExistentService')]
        ->setConstructArguments(array(array(
            'moo' => array(
                'class' => 'Doppo\Tests\Data\Moo'
            ),
        )))
        ->getMock();

    $container
        ->expects($this->once())
        ->method('buildExistentService')
        ->with($this->equalTo('moo'))
        ->willReturn(new \Doppo\Tests\Data\Moo);

    $container->compile();
    $container->get('moo');
    $container->get('moo');
}

Todos los tests que podamos hacer en este punto, tienen que partir de la base que la compilación del container funciona correctamente.

Los tests que se presentan cubren las siguientes situaciones:

  • Dada una configuración válida, el método get devuelve las instancias esperadas.
  • El container lanza una Exception cuando se llama a get y no está compilado.
  • El container lanza una Exception si se busca un servicio inexistente.
  • Un servicio es creado una (y sola) una vez, aunque se pida más de una vez.

Para volver estos tests en verde, vamos a implementar esta feature.

/**
 * Get service instance
 *
 * @param string $serviceName Service Name
 *
 * @return mixed Service instance
 *
 * @throws DoppoNotCompiledException      Container not compiled yet
 * @throws DoppoServiceNotExistsException Service not found
 */
public function get($serviceName)
{
    if (!$this->compiled) {
        throw new DoppoNotCompiledException(
            'Container should be compiled before being used'
        );
    }

    /**
     * The service is found as an instance, so we can be ensured that the
     * value inside this position is a valid Service instance
     */
    if (isset($this->serviceInstances[$serviceName])) {
        return $this->serviceInstances[$serviceName];
    }

    /**
     * Otherwise, we must check if the service defined with its name has
     * been compiled
     */
    if (!$this->services->has($serviceName)) {
        throw new DoppoServiceNotExistsException(
            sprintf(
                'Service "%s" not found',
                $serviceName
            )
        );
    }

    return $this->serviceInstances[$serviceName] = $this->buildExistentService($serviceName);
}

/**
 * Build service. We assume that the service exists and can be build
 *
 * @param string $serviceName Service Name
 *
 * @return mixed Service instance
 */
protected function buildExistentService($serviceName)
{
    $serviceDefinition = $this->services->get($serviceName);
    $serviceReflectionClass = new ReflectionClass($serviceDefinition->getClass());
    $serviceArguments = array();

    /**
     * Each argument is built recursively. If the argument is defined
     * as a service we will return the value of the get() call.
     *
     * Otherwise, if is defined as a parameter we will return the
     * parameter value
     *
     * Otherwise, we will treat the value as a plain value, not precessed.
     */
    $serviceDefinition
        ->getArgumentChain()
        ->each(function (Argument $argument) use (&$serviceArguments) {

            $argumentValue = $argument->getValue();

            if ($argument instanceof ServiceArgument) {
                $serviceArguments[] = $this->get($argumentValue);

            } elseif ($argument instanceof ParameterArgument) {
                $serviceArguments[] = $this->getParameter($argumentValue);

            } else {
                $serviceArguments[] = $argumentValue;
            }
        });

    return $serviceReflectionClass->newInstanceArgs($serviceArguments);
}

Como vemos, el método buildExistentService es protected en lugar de private. Hay una razón sólida para que sea así, y es que una de las cosas más importantes a la hora de trabajar con un container es el hecho de que un servicio es creado una (y solo una) vez.

Para esto es importante poder Mock el método en cuestión y verificar que solo se llama una vez, y para esto debe formar parte de la API de la clase (método público y protected).

Parameter values

Finalmente queremos testear que los parámetros están disponibles mediante el método getParameter. De la misma forma que hemos hecho con los servicios, creamos los tests que comprueban los valores y luego implementamos.

/**
 * Testing get method with good values
 */
public function testGetParameterOK()
{
    $container = new Doppo(array(
        'my.parameter' => 'my.value',
    ));

    $container->compile();
    $this->assertEquals('my.value', $container->getParameter('my.parameter'));
}

/**
 * Testing get method with bad values
 *
 * @dataProvider dataGetParameterFail
 * @expectedException \Exception
 */
public function testGetParameterFail($parameterName)
{
    $container = new Doppo(array(
        'my.parameter' => 'my.value',
    ));
    $container->compile();
    $container->getParameter($parameterName);
}

/**
 * Data for testGetParameterFail
 *
 * @return array
 */
public function dataGetParameterFail()
{
    return array(
        array('my.nonexisting.parameter'),
        array(true),
        array(false),
        array(null),
    );
}

/**
 * Testing getParameter method with a non-compiled container
 *
 * @expectedException \Exception
 */
public function testGetParameterWithoutCompile()
{
    $container = new Doppo(array());
    $container->getParameter('my.parameter');
}

En estos tests estamos cubriendo los siguientes casos:

* Dada una configuración válida, el método `getParameter` devuelve los valores esperadas.
* El container lanza una Exception cuando se llama a `getParameter` y no está compilado.
* El container lanza una Exception si se busca un parámetro inexistente.

Finalmente, la implementación.

```php
/**
 * Get parameter value
 *
 * @param string $parameterName Parameter Name
 *
 * @return mixed Parameter value
 *
 * @throws DoppoNotCompiledException        Container not compiled yet
 * @throws DoppoParameterNotExistsException Parameter not found
 */
public function getParameter($parameterName)
{
    if (!$this->compiled) {
        throw new DoppoNotCompiledException(
            'Container should be compiled before being used'
        );
    }

    if (!$this->parameters->has($parameterName)) {
        throw new DoppoParameterNotExistsException(
            sprintf(
                'Parameter "%s" not found',
                $parameterName
            )
        );
    }

    return $this
        ->parameters
        ->get($parameterName)
        ->getValue();
}

Si ahora lanzamos toda la batería de tests, deberíamos poder comprobar que todos están en verde, por lo que hemos logrado construir un DIC utilizando TDD.

Y ahora que?

Muchas cosas a partir de ahora.

  • Recordemos que tenemos el método buildExistentService protected, por lo que podemos sobreescribirlo. Que os parece crear una capa de caché? De esta forma una vez compilamos el container, podemos bolcar toda la configuración en un fichero plano, como lo hace el Dependency Injection de Symfony.
  • Podéis encontrar una implementación completa, con caché incluida, en el repositorio Doppo.

Most kind of projects already exist. What I mean is that I strongly think that lots of developers in the era of great documentations abuse the shallowness these documentations provide us, and this would be great, at least, as a simple users. But what really makes us great developers is what superb implementations teaches us.

This is the reason why the documentation of the project I have been working on for this post starts with one of the most known phrase from the homo-developer: Oh, no way, another Dependency Injection Container written in PHP?. Well, I am afraid it is, and I'm sorry to disappoint all the people expecting from this post a great source of new ideas and the most powerful DIC written ever.

It is not.

What I promise since this moment is an easy implementation of what I consider a must for every single OOP developer, using step-by-step development, and emulating a pair-programming exercise. I want to explain with this example how to handle a project like this using a great strategic tool called TDD, and how can TDD help us with the entire project implementation.

Let's start.

What is the first thing we should do in front of any kind of technical project? Yes, sure, some of you may answer this question with a simple... Go on, mate, implement, implement and implement. So yes, maybe this is a great approach for many kind of projects, but this disables you to really specify what you want to implement, even if all the information or the specification is in your mind.

Don't believe in you, believe in tests.
Marc Morera, right now.

Given that premise, we will split all the post in three kind of sections. This sections will be as small as possible and could be repeated as many times as we need. The only condition we must ensure is that always have the same order.

  • Specification
  • Tests
  • Implementation

Indeed this condition is just a condition for this exercise. If you apply TDD in your project, and following the PDD (Pragmatic Driven Development)[http://mmoreram.com/blog/2014/10/06/pdd/] philosophy, be ensure you are comfortable with it. Some projects just implement after specifying, and then create the tests.

For this post, you should know some specific concepts. Just take a look if you don't know them at all.

Ok, let's move on. As we have the full specification of the project, we will iterate between second and third step, and we will define the first one in the beginning.

Container definition

  • Well, a DIC? How would I use it?
  • Not much, just some basic things.
  • Show me...
  • Okay
    • Let's work with Parameters and Services
    • A Parameter will be a non-empty identifier assigned to a basic PHP type (string, integer, Object, callback)
    • A Service will be a non-empty identifier assigned to an instance definition, given its class namespace and an array of constructor arguments
    • Each one of this arguments can reference another service, following the format @service_id, a parameter, following the format ~parameter_id and a simple PHP type
    • Given that is a container, any service will be instanced once, and just once. This instantiation will be effective at the call time.
    • To retrieve the service instance we will use the public method $this->get('service_id');
    • To retrieve any parameter we will use the public method $this->getParameter('parameter_id');
    • To define our container we will pass the configuration as an argument
    • If the compilation fails, the container will throw a single PHP Exception

Container compilation, Tests

To make our container more

Let's start implementing our configuration specification. So, what is the configuration? The container should know all the information about how the services have to be built, and all the values of the parameters. Let's see a small example about our first approximation, always following our specification.

$configuration = [
    'my_service' => [
        'class' => 'My\Class\Namespace',
        'arguments' => [
            '@my_other_service',
            '~my_parameter',
            'simple_value',
        ]
    ],
    'my_other_service' => [
        'class' => 'My\Other\Class\Namespace',
    ],
    'my_parameter' => 'parameter_value',
];

Let's analise this configuration block.

  • A service named my_service, whose instance is created with the class My\Class\Namespace and whose constructor needs these parameters.
    • A service named my_other_service
    • A parameter named my_parameter
    • The plain string "simple_value"
  • A service named my_service, whose instance is created with the class My\Other\Class\Namespace and whose constructor is empty.
  • A parameter named my_parameter with the string "parameter_value" as its value

All the exercise is based on the assumption that all the classes exist and are properly defined, so this configuration seems to be perfectly compiled. What is the container compilation? Well, imagine that you don't want to work with a simple array internally. We need to check and transform into an internal structure all the configuration. This action will have the name of "compile" the container.

After the container is compiled, this becomes a read-only instance with just getters.

So, we've seen a valid configuration, but... how about a wrong configuration?

$configuration = [
    '' => [
        'class' => 'My\Non\Existing\Class\Namespace',
        'arguments' => [
            '@my_non_existing_service',
            '~my_non_existing_parameter',
            'simple_value',
        ]
    ],
    '' => 'parameter_value',
];

This configuration is wrong for many reasons. One by one...

  • The identifier of the service is empty, and all services should have a non-empty name
  • The identifier of the parameter is empty, and all parameters should have a non-empty name
  • The namespace My\Non\Existing\Class\Namespace does not exist.
  • The service my_non_existing_service is not defined
  • The parameter my_non_existing_parameter is not defined

As you can see we are defining the behavior of our library without implementing any single line. This should seem strange, but is not. It is quite interesting and useful to be sure that what we implement is exactly what we want to implement.

Second stage of this iteration is writing some unit tests. In this case we will use PHPUnit because is the library I am used to working with. If you don't know how it works, maybe is interesting for you to read some examples about what PHPUnit can do for you.

Our first test may look like this

/**
 * This method just will test that, given a configuration, the container will
 * be built properly
 */
public function testBuildOk()
{
    $configuration = [
        'my_service' => [
            'class' => 'My\Class\Namespace',
            'arguments' => [
                '@my_other_service',
                '~my_parameter',
                'simple_value',
            ]
        ],
        'my_other_service' => [
            'class' => 'My\Other\Class\Namespace',
        ],
        'my_parameter' => 'parameter_value',
    ];
    $container = new Doppo($configuration);
    $container->compile();
}

This tests is not asserting anything, but ensuring that any Exception will be thrown during the compile call. Enough in this case.

We should also test every single configuration we want to be compilable.

/**
 * This method just will test that, given a configuration, the container will
 * be built properly
 *
 * @dataProvider dataBuildOk
 */
public function testBuildOk(array $configuration)
{
    $container = new Doppo($configuration);
    $container->compile();
}

/**
 * data for testBuildOk
 */
public function dataBuildOk()
{
    return [
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_other_service' => [
                'class' => 'My\Other\Class\Namespace',
            ],
            'my_parameter' => 'parameter_value',
        ]],
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => []
            ],
        ]],
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
            ],
        ]],
    ];
}
Attention: We are not testing that all the instances can be instanced
properly, but the configuration format.

Nice test, right? Indeed it is, and we should be proud of it. In this case we are testing that our container can be compiled using each configuration defined in our data provider. If you want to know more about @dataProvider you can review the PHPUnit dataProvider documentation.

Let's define all the cases where our compiler should fail. As we have defined previously, the container will throw an Exception if the compilation fails.

/**
 * This method just will test that, given a configuration, the container will
 * be built properly
 *
 * @dataProvider dataBuildFail
 * @expectedException \Exception
 */
public function testBuildFail(array $configuration)
{
    $container = new Doppo($configuration);
    $container->compile();
}

/**
 * data for testBuildFail
 */
public function dataBuildFail()
{
    return [
        // Service my_other_service not defined
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_parameter' => 'parameter_value',
        ]],

        // Parameter my_parameter not defined
        [[
            'my_service' => [
                'class' => 'My\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_other_service' => [
                'class' => 'My\Class\Namespace',
            ],
        ]],

        // Service namespace My\Non\Existing\Class\Namespace not found
        [[
            'my_service' => [
                'class' => 'My\Non\Existing\Class\Namespace',
                'arguments' => [
                    '@my_other_service',
                    '~my_parameter',
                    'simple_value',
                ]
            ],
            'my_other_service' => [
                'class' => 'My\Class\Namespace',
            ],
            'my_parameter' => 'parameter_value',
        ]],

        // Service invalid name
        [[
            '' => [
                'class' => 'My\Class\Namespace',
            ],
        ]],

        // Parameter invalid name
        [[
            '' => 'parameter_value',
        ]]
    ];
}

For that, we can use the annotation @expectedException. To know more about this cool feature, you can review the PHPUnit exceptionExpected documentation

So, at this point we have defined what should be a valid and compilable Container. Let's work on that.

Remember, you are doing tests, and the real reason of the tests is the result of executing them. When you create firstly all tests you can imagine that, if you run them, they will not pass. None of them. So that's the point, our great work will be to make it happen, to make them green. And when they are green, we can continue our implementation, adding more red tests and turning them green once and again.

That is the magic of the TDD.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment