TDD en PHP. Un ejemplo con colecciones (5)
Todavía nos quedan unas cuentas cosas pendientes en nuestra lista:
- Que pueda agregar la Collection (reduce)
- Poder crear una Collection a partir de un array de objetos
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Seleccionar cuál es la tarea que vamos a afrontar a continuación depende sobre todo de lo que deseemos o de lo que necesitemos. En un entorno de trabajo real esa decisión vendrá marcada por aquellas características a las que damos más valor y que ayudan a configurar un producto mínimo viable lo antes posible.
Pero en nuestro ejercicio la selección de la próxima tarea se mueve por otros criterios, como puede ser que nos ayude a demostrar o ilustrar algún punto concreto de la metodología de TDD. Así, en esta serie hemos trabajado en lo siguiente:
En cuanto a la metodología TDD:
- La importancia de escoger buenos tests mínimos que fallen
- Qué código mínimo de producción escribir para que el test pase
Es decir, cumplir las tres leyes de TDD de Robert C. Martin:
- No escribirás código de producción sin antes escribir un test que falle.
- No escribirás más de un test unitario suficiente para fallar (y no compilar es fallar)
- No escribirás más código del necesario para hacer pasar el test.
Y, por otro lado, algunas técnicas prácticas, como:
- Descartar o posponer los tests que no fallan a la primera (violación de la primera ley de TDD).
- Usar clases anónimas para disponer de test doubles de bajo coste y desechables.
- Usar el self-shunt cuando necesitamos algún test double, lo que nos evita tener que tirar de mocks o inventarnos clases sin necesidad. Esto es: usar la propia clase TestCase como double.
- Usar el código de producción como test para refactorizar el test: vamos modificando el test procurando que se mantenga en verde.
- Identificar casos límite al descubrir que fallan tests anteriores, y que antes pasaban, en el último paso de implementación.
Y también alguna técnica organizativa útil:
- Usar una lista de tareas para anotar en ella todas las ideas que se nos van ocurriendo, nuevos tests que deberíamos crear, etc, de modo que podamos mantener nuestra atención centrada en el test concreto en el que estamos trabajando.
Reduciendo colecciones
El primer elemento de la lista de tareas es implementar el método <code>reduce</code>. El concepto de reduce consiste en “resumir” la colección en un valor que agregue de algún modo sus elementos por medio de la función que le pasemos. Para ello, <code>reduce</code> tiene que poder arrastrar un acumulador que sea actualizado y devuelto por la función reductora. También podemos necesitar un valor para iniciar ese acumulador.
<code>reduce</code> puede devolver cualquier cosa, desde un número a un array o incluso algún objeto. No hay limitaciones aquí. Lo más importante es que aquello que devuelva la función de reducción debe pasársele como parámetro, junto con el elemento actual.
En fin, ¿cuál podría ser el test más sencillo que falle para este método? Pues siguiendo la línea de los artículos anteriores podemos empezar por el test de la colección vacía. Una colección vacía no acumularía nada ni podría reducirse a nada, así que parece bastante razonable esperar que nos devuelva <code>null</code>. Lo malo es que ese test va a pasar a la primera puesto que cualquier método que no devuelva nada explícitamente devolverá null.
Por lo tanto, este test no nos vale. ¿Qué podríamos hacer entonces? Resulta que hemos mencionado que podríamos pasar un valor inicial del acumulador, por lo que en el caso de la lista vacía podríamos devolver ese mismo valor ya que al no tener elementos que iterar no se podría aplicar la función de reducción.
1 public function testReduceSouldReturnInitialValueForEmptyCollection()
2 {
3 $sut = $this->getCollection();
4 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
5 return $accumulator + 1;
6 }, 0);
7 $this->assertEquals(0, $result);
8 }
El test fallará por razones obvias y nos pide crear el método <code>reduce</code>, cosa que ya podemos hacer con la implementación obvia devolviendo <code>0</code>, es decir, el mínimo código para que el test pase.
1 public function reduce(Callable $function, $initial)
2 {
3 return 0;
4 }
Bien, ¿y por qué no devolver directamente el valor que pasamos en $initial?
Después de un tiempo practicando TDD puedes pensar que este baby step es demasiado baby y que puedes lidiar con confianza con algunos pasos más grandes. Y no te equivocarías. Como he mencionado en algún momento de la serie, estos pasos se van adaptando a las circunstancias y los puedes ampliar o reducir dependiendo, precisamente, de tu confianza en lo que estás haciendo.
Pero yo ahora prefiero hacer que los tests me vayan marcando el camino. Así, en lugar de dar un paso grande, voy a dar uno más pequeño, que además me servirá para probar que <code>$initial</code> puede ser cualquier tipo de valor. Crearé otro test.
1 public function testReduceShouldAcceptAnyTypeForInitialValue()
2 {
3 $sut = $this->getCollection();
4 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
5 return $accumulator + 1;
6 }, "");
7 $this->assertEquals("", $result);
8 }
Este test falla y, al fallar, me fuerza a una nueva implementación no tan obvia y más general.
Si usase la implementación obvia mínima para pasar este nuevo test, que sería devolver la cadena vacía, el test anterior dejaría de pasar. Eso indica que tengo que implementar algo que pueda satisfacer ambos tests a la vez. Y eso, niñas y niños, es la razón por la que deberíamos dar pasos cortos para forzar que los tests nos digan lo que debemos hacer.
En este caso, la implementación más sencilla para eso es devolver el propio parámetro.
1 public function reduce(Callable $function, $initial)
2 {
3 return $initial;
4 }
Hemos dicho que <code>reduce</code> puede devolver cualquier cosa, pero pasando un valor inicial es bastante lógico suponer que el tipo devuelto por reduce es el mismo que el del valor inicial que se pasa. Debería ser obvio que probar esto, en este momento, es inútil puesto que al devolver lo mismo que recibimos el test no nos va a aportar nada. Por tanto, deberíamos buscar otra cosa para probar.
Por ejemplo, podríamos probar que la función de reducción se aplica para una colección de un elemento.
Nuestra función de reducción de prueba es muy sencilla y se limita a incrementar el acumulador que se le pasa como segundo parámetro, así que nuestro nuevo test podría ser este:
1 public function testReduceShouldApplyCallableToOneElement()
2 {
3 $sut = $this->getCollection();
4 $sut->append($this);
5 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
6 return $accumulator + 1;
7 }, 0);
8 $this->assertEquals(1, $result);
9 }
Como el test falla, implementemos algo para que pase:
1 public function reduce(Callable $function, $initial)
2 {
3 return $function(reset($this->elements), $initial);
4 }
Y, aunque el nuevo test pasa, se nos rompen los dos test anteriores. Nuestra implementación tiene que lidiar con un caso límite que, ¡sorpresa! es el de la colección vacía.
1 public function reduce(Callable $function, $initial)
2 {
3 if (!$this->count()) {
4 return $initial;
5 }
6 return $function(reset($this->elements), $initial);
7 }
Y con esta implementación volvemos a verde.
He mencionado varias veces que la colección vacía es un caso límite, pero no he explicado cómo podemos decir esto. Aprovecho ahora:
La colección vacía es un caso límite porque no puede ser tratado por el algoritmo general. Es una situación especial que no cumple los supuestos que asumimos respecto a las situaciones cubiertas por el algoritmo. Normalmente podemos detectar estos casos con TDD cuando falla un test anterior a la implementación de una solución general.
Podemos prever algunos casos límite si conocemos el dominio. Por ejemplo, en el caso de las colecciones, tenemos tres casos claros:
- La colección no tiene ningún elemento.
- La colección tiene un elemento.
- La colección tiene más de un elemento.
Por esa razón intentamos crear tests que cubran las tres situaciones. Al hacerlo podemos descubrir varias cosas:
- Al implementar una solución más general para pasar el test de un caso, se rompen tests previos: eso indicaría que los tests rotos se aplican sobre un caso especial.
- Al implementar una solución más general para pasar el test de un caso, no se rompen tests previos: indicaría que los casos tratados por esos tests no son especiales.
- Al crear un nuevo test para probar otro caso, el test falla: indicaría que no hemos implementado una solución lo bastante general.
- Al crear un nuevo test para probar otro caso, el test pasa a la primera: indicaría que ya hemos implementado una solución general.
En principio nos quedaría probar con una colección de más elementos. El resultado de este test es previsible: tenemos un fallo porque la solución no es lo bastante general.
1 public function testReduceShouldApplyFunctionToSeveralElements()
2 {
3 $sut = $this->getCollection();
4 $sut->append($this);
5 $sut->append($this);
6 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
7 return $accumulator + 1;
8 }, 0);
9 $this->assertEquals(2, $result);
10 }
La razón es que no estamos iterando:
1 public function reduce(Callable $function, $initial)
2 {
3 if (!$this->count()) {
4 return $initial;
5 }
6 foreach ($this->elements as $element) {
7 $initial = $function($element, $initial);
8 }
9 return $initial;
10 }
Y con esto resulta que hemos conseguido implementar <code>reduce</code>. Algo que podemos tachar de la lista de tareas.
- Poder crear una Collection a partir de un array de objetos
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Métodos útiles para nuestras colecciones
En nuestra lista nos quedan varios métodos que pueden ser de utilidad para crear nuestras colecciones.
El primero de ellos tiene que ver con la posibilidad de crear una colección a partir de un array, se supone que de objetos.
En este caso, parece buena idea usar un <em>named constructor</em>, que instancie una nueva colección a partir de un array que contenga al menos un objeto. Si el array estuviese vacío no podríamos instanciar <code>Collection</code> porque no sabríamos el tipo de objetos que contiene, salvo que se lo indicásemos explícitamente, que es lo que hacemos con <code>Collection::of</code>.
Por otra parte, pueden existir arrays no válidos, aparte del vacío, como aquellos que no contengan objetos o que lleven mezclados objetos de distinto tipo, con elementos que no sean objetos.
Así que tenemos que poner algunas reglas para definir el comportamiento de este método, que será lo que testeemos:
- Si el array está vacío, lanzar una excepción.
- Si el primer elemento del array no es un objeto válido lanzar una excepción.
- Si el array tiene al menos un elemento que es un objeto, crear la colección, tomando como tipo el del primer objeto presente en el array.
- Una vez determinado el tipo de la colección, añadimos todos los objetos de ese tipo.
- Si encontramos algún objeto de otro tipo lanzamos una excepción.
Así que ahora tenemos una lista específica de tareas para desarrollar este método.
¿Cuál sería el mejor punto para empezar? Podríamos hacerlo siguiendo la lista de tareas. Otro enfoque sería comenzar por la situación válida más sencilla (la tercera de nuestra lista) y añadir posteriormente las demás. La verdad es que, como veremos, va a dar un poco igual.
Particularmente no me gusta comenzar por un caso que lanza una excepción, se llaman así por ser excepcionales, así que me voy directamente al primer caso de uso normal y decido que este será el test mínimo:
1 public function testCollectShouldReturnInstanceOfCollection()
2 {
3 $sut = Collection::collect([]);
4 $this->assertAttributeEquals(\stdClass::class, 'type', $sut);
5 }
El test falla porque no existe el método collect. Lo creamos y observamos que vuelve a fallar porque no devolvemos nada y es, por tanto, momento de implementar alguna solución.
La implementación más sencilla podría ser esta:
1 public static function collect(array $array)
2 {
3 return Collection::of(\stdClass::class);
4 }
Que nos sirve para pasar el test.
Ahora quiero probar que el método toma en cuenta el array que le pasamos para instanciar la clase. Para eso hago un test que falle.
1 public function testCollectShouldUseFirstElementToDecideCollectionType()
2 {
3 $sut = Collection::collect([new \stdClass()]);
4 $this->assertAttributeEquals(\stdClass::class, 'type', $sut);
5 }
Y como falla, me obliga a implementar. Si ahora forzase a crear una <code>Collection</code> con <code>CollectionTest::class</code> el test anterior fallaría, por lo que debo implementar una solución más general.
1 public static function collect(array $elements)
2 {
3 $type = get_class($elements[0]);
4 return Collection::of($type);
5 }
Este test pasa, pero falla el anterior. Como hemos visto antes, un test anterior que falla suele implicar un caso límite que aparece al intentar generalizar un algoritmo. Pero es que este caso coincide con uno de los casos que queríamos controlar en particular, el array vacío que iba a generar una excepción.
Necesitamos un test que compruebe específicamente este caso. Con esto me doy cuenta de que he comenzado por un test que no sirve, lo que me muestra que siguiendo la metodología TDD los tests parecen cuidarse a sí mismos. Es decir: incluso no teniendo las cosas muy claras al principio, TDD nos va llevando hacia un camino productivo.
En resumidas cuentas, eliminamos el test malo y preparamos un test adecuado a lo que queremos probar ahora:
1 public function testShouldFailWithExceptionCollectingEmptyArray()
2 {
3 $this->expectException(\InvalidArgumentException::class);
4 Collection::collect([]);
5 }
Hay que implementar para volver a verde:
1 public static function collect(array $elements)
2 {
3 if (!count($elements)) {
4 throw new \InvalidArgumentException('Can\'t collect an empty array');
5 }
6 $type = get_class($elements[0]);
7 return Collection::of($type);
8 }
Ahora tenemos que probar que <code>collect</code> es capaz de llenar la colección con los objetos que se encuentran en el array. El test mínimo que lo demuestra podría ser este:
1 public function testShouldPopulateCollectionWithUniqueElementInArray()
2 {
3 $sut = Collection::collect([
4 $this
5 ]);
6 $this->assertEquals(1, $sut->count());
7 }
Y una implementación mínima sería la siguiente:
1 public static function collect(array $elements)
2 {
3 if (!count($elements)) {
4 throw new \InvalidArgumentException('Can\'t collect an empty array');
5 }
6 $type = get_class($elements[0]);
7 $collection = Collection::of($type);
8 $collection->append(reset($elements));
9 return $collection;
10 }
Para forzarnos a implementar el método general necesitamos un nuevo test, que pruebe que un array de varios elementos genera una colección con esos elementos.
1 public function testShouldPopulateCollectionWithSeveralElementsInArray()
2 {
3 $sut = Collection::collect([
4 $this,
5 $this
6 ]);
7 $this->assertEquals(2, $sut->count());
8 }
Para pasar el test, ya podríamos implementar el método general:
1 public static function collect(array $elements)
2 {
3 if (!count($elements)) {
4 throw new \InvalidArgumentException('Can\'t collect an empty array');
5 }
6 $type = get_class($elements[0]);
7 $collection = Collection::of($type);
8 foreach ($elements as $element) {
9 $collection->append($element);
10 }
11 return $collection;
12 }
La siguiente tarea que tenemos es lanzar una excepción si algún elemento del array no es del tipo adecuado para la colección. Podríamos hacer un test para probarlo, pero este test va a pasar a la primera.
1 public function testShouldFailWithExceptionIfWrongTypeElementFound()
2 {
3 $this->expectException(\UnexpectedValueException::class);
4 Collection::collect([
5 $this,
6 new \stdClass()
7 ]);
8 }
Esto era de esperar porque ya estaba contemplado en el método <code>append</code>, al que recurrimos para añadir los elementos del array a la colección en vez de incluirlos a mano en el almacén interno. Este patrón se llama <em>self-encapsulation</em> y consiste precisamente en que una clase utiliza internamente métodos para alterar sus propiedades, en vez de manejarlas directamente, de tal manera que estos métodos pueden encapsular guardas, saneamientos y otras operaciones.
Ahora podemos considerar que hemos terminado de implementar el método <code>collect</code>. Es momento de refactorizarlo.
Los tests nos protegen contra problemas derivados de los cambios que hagamos. Al refactorizar sólo estamos cambiando la implementación, no la interfaz ni el comportamiento público, y eso es lo que nos aseguran los tests en este momento.
1 public static function collect(array $elements)
2 {
3 if (!count($elements)) {
4 throw new \InvalidArgumentException('Can\'t collect an empty array');
5 }
6 $collection = Collection::of(get_class($elements[0]));
7 return array_map(function ($element) use ($collection) {
8 $collection->append($element);
9 }, $elements);
10 }
Aquí está nuestra lista de tareas actualizada.
- Método toArray y/o mapToArray que devuelva los elementos de Collection como un array
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Devolviendo el contenido de la colección
Usar colecciones puede ser muy útil y elegante, pero si interactuamos con código de terceros es muy posible que necesitemos disponer del contenido de la colección en un array. Lo cierto es que lo estamos almacenando internamente en un array por lo que, simplemente, podríamos devolverlo y punto.
Pero, como siempre, deberíamos probar eso con un test.
1 public function testShouldMapEmptyCollectionToEmptyArray()
2 {
3 $sut = $this->getCollection();
4 $this->assertEquals([], $sut->toArray());
5 }a
Como suele pasar con estos tests iniciales, no existe el método y nos pide una implementación mínima, que es bastante obvia.
1 public function toArray()
2 {
3 return [];
4 }
Para que sea útil, el método debe trabajar con Collections que tengan algún elemento.
1 public function testShouldReturnArrayFromCollection()
2 {
3 $sample = [$this];
4 $sut = Collection::collect($sample);
5 $this->assertEquals($sample, $sut->toArray());
6 }
La siguiente implementación obvia romperá nuestro test anterior sobre la colección vacía:
1 public function toArray()
2 {
3 return $this->elements;
4 }
Así que hay que contemplar el caso límite, cosa que no nos debería sorprender:
1 public function toArray() : array
2 {
3 if (!$this->elements) {
4 return [];
5 }
6 return $this->elements;
7 }
No merece la pena probar nuevos tamaños de colección, cualquier test que se nos ocurra al respecto pasará y, por tanto, no aportará ninguna información que nos fuerce a realizar cambios en la implementación.
Pero lo cierto es que también planteamos un método mapToArray. La idea es la siguiente:
En algunas ocasiones nos interesa convertir nuestros objetos a una estructura de array asociativo (diversos mecanismos de persistencia nos piden esto). Por desgracia nuestra definición de Collection impide que podamos mapear los objetos como array para generar una “colección de arrays”, aunque existe un atajo:
1 $collectionArray = $collection->reduce(function(Persistible $element, $accumulator)\
2 {
3 $accumulator[] = $element->toArray();
4 }, array());
Esta solución funciona, pero sería interesante encapsularla, de modo que fuese más fácil de usar. Una posibilidad es crear un método mapToArray, pero ¿por qué no encapsularla en toArray pasando la función de conversión a array como un parámetro opcional? Al fin y al cabo, generar un array a partir de la colección es el caso más simple de mapeo.
Por supuesto, debemos probar esto con un test.
El caso de la colección vacía ya lo hemos probado con el test anterior, por lo que podemos pasar al siguiente test mínimo:
1 public function testShouldBeAbleToMapCollectionToArray()
2 {
3 $sut = $this->getCollection();
4 $sut->append($this);
5 $this->assertEquals(['mapped'], $sut->toArray(function(CollectionTest $eleme\
6 nt) {
7 return 'mapped';
8 }));
9 }
Como no hemos implementado ningún mapeo, el test no pasa.
La forma de hacerlo pasar es sencilla:
1 public function toArray() : array
2 {
3 if (!$this->elements) {
4 return [];
5 }
6 return ['mapped'];
7 }
Con esto, el test pasa, pero rompemos un test anterior, el de la definición actual del método toArray. Es buena cosa, porque nos obliga a implementar algo diferente.
Por ejemplo, esto:
1 public function toArray(Callable $function = null) : array
2 {
3 if (!$this->elements) {
4 return [];
5 }
6 if (!$function) {
7 return $this->elements;
8 }
9 return ['mapped'];
10 }
Nos queda menos. El siguiente test probará que podemos mapear dos elementos en el array, pero aquí voy a hacer algo que puede parecer un churro pero que me va a servir para hacer una explicación que hasta ahora he pasado por alto sobre la naturaleza de los baby-steps.
Pero primero, el test:
1 public function testShouldBeAbleToMapCollectionWithTwoElementsToArray()
2 {
3 $sut = $this->getCollection();
4 $sut->append($this);
5 $sut->append($this);
6 $this->assertEquals(['mapped', 'mapped'], $sut->toArray(function(CollectionT\
7 est $element) {
8 return 'mapped';
9 }));
10 }
Falla. Implementemos una solución:
1 public function toArray(Callable $function = null) : array
2 {
3 if (!$this->elements) {
4 return [];
5 }
6 if (!$function) {
7 return $this->elements;
8 }
9 return [
10 'mapped',
11 'mapped'
12 ];
13 }
¿Cómo te quedas?
Nuestro último test pasa, nuestro test anterior se rompe. Este baby-step parece ridículo, pero no lo es, de ningún modo. Vamos a ver lo que nos aporta:
- En primer lugar, nos ha permitido tener un test que pasa y que es válido, facilitándonos cambiar una implementación para cubrir un nuevo caso.
- Pero al fallar un test anterior, nos dice que debemos buscar una implementación que pueda dar cuenta de los dos tests. Es decir, un algoritmo más general.
- En tercer lugar, la propia solución apunta que debemos iterar elementos para lograr el resultado deseado.
Así que vamos a implementar de otra manera, en este caso, dando un paso un poco más largo:
1 public function toArray(Callable $function = null) : array
2 {
3 if (!$this->elements) {
4 return [];
5 }
6 if (!$function) {
7 return $this->elements;
8 }
9 $map = [];
10 foreach ($this->elements as $element) {
11 $map[] = $function($element);
12 }
13 return $map;
14 }
Esta implementación ya es lo bastante general como para que no necesitemos más tests. Posiblemente podamos refactorizar nuestra solución y hacerla más concisa:
1 public function toArray(Callable $function = null) : array
2 {
3 if (!$this->elements) {
4 return [];
5 }
6 if (!$function) {
7 return $this->elements;
8 }
9 return array_map($function, $this->elements);
10 }
La lista se reduce y ya estamos acabando:
- Método isEmpty que nos diga si la colección está vacía
- Método getType devuelve tipo de la colección
Métodos de utilidad
Tenemos un par de métodos de utilidad para nuestra Collection y que no hubiera estado de más implementar antes. Lo bueno es que serán fáciles de implementar y nos servirán para aprender un par de cosas más:
1 public function testShouldGetTheTypeOfCollection()
2 {
3 $sut = Collection::of(CollectionTest::class);
4 $this->assertEquals(CollectionTest::class, $sut->getType());
5 }
Testear un método que va a dar un resultado obvio como un getter no tiene mucho sentido, a no ser que exista una expectativa razonable de que no va a ser un getter “tonto” y que, con el tiempo, podría recibir algún tipo de implementación. En ese caso, el test nos serviría para cubrir una posible regresión.
Pero en muchos casos estos test simplemente no se hacen hasta que son necesarios. Los únicos beneficios que se me ocurre que podría ofrecer el test de un getter “tonto” serían:
- Forzarnos a hacer la implementación
- Contribuir al índice de cobertura de código
La implementación es obvia:
1 public function getType()
2 {
3 return $this->type;
4 }
Por último, isEmpty tiene un poco más de comportamiento. Es un método de utilidad para encapsular una información que podemos obtener de otra manera, aunque un poco más alambicada:
1 if ($collection->count() === 0) { // Collection is empty }
Hagamos un test que falle:
1 public function testShouldTellIfCollectionIsEmpty()
2 {
3 $sut = $this->getCollection();
4 $this->assertTrue($sut->isEmpty());
5 }
Obviamente nos pide implementar y devolver true:
1 public function isEmpty() : bool
2 {
3 return true;
4 }
Pero si la colección tiene elementos, debería devolver false.
1 public function testShouldTellIfCollectionIsNotEmpty()
2 {
3 $sut = $this->getCollection();
4 $sut->append($this);
5 $this->assertFalse($sut->isEmpty());
6 }
Y la implementación necesaria es sencilla:
1 public function isEmpty() : bool
2 {
3 return !$this->elements;
4 }
Y, con esto, terminamos.
Refactor final
Hemos desarrollado nuestra clase Collection y tachado todos los elementos de la lista. Seguramente queda mucho campo para mejorar esta clase y, tal vez, implementar más métodos. Por el momento, la dejamos así.
Puede ser buen momento para refactorizar el código, que está completamente protegido por los tests. De este modo, podemos encontrar implementaciones mejores o más elegantes que, en un futuro, nos permitan intervenir sobre el código, bien para corregir problemas, bien para añadir nuevas funcionalidades o modificar comportamientos de la clase.
Por mi parte, voy a revisar cuestiones como los return type de los métodos y refactorizar algunas cosas con auto-encapsulación y, si fuese posible, eliminar algunos bucles También puede ser el momento de reordenar los métodos para agruparlos por afinidad. Este ha sido el resultado:
1 <?php
2
3 namespace Fi\Collections;
4
5 class Collection
6 {
7 /**
8 * @var array
9 */
10 private $elements;
11 /**
12 * @var string
13 */
14 private $type;
15
16 private function __construct(string $type)
17 {
18 $this->type = $type;
19 }
20
21 public static function of(string $type) : Collection
22 {
23 return new self($type);
24 }
25
26 public static function collect(array $elements)
27 {
28 if (!count($elements)) {
29 throw new \InvalidArgumentException('Can\'t collect an empty array');
30 }
31
32 $collection = Collection::of(get_class($elements[0]));
33
34 array_map(function ($element) use ($collection) {
35 $collection->append($element);
36 }, $elements);
37
38 return $collection;
39 }
40
41 public function count()
42 {
43 return count($this->elements);
44 }
45
46 public function append($element) : void
47 {
48 $this->guardAgainstInvalidType($element);
49 $this->elements[] = $element;
50 }
51
52 protected function guardAgainstInvalidType($element) : void
53 {
54 if (!$this->isSupportedType($element)) {
55 throw new \UnexpectedValueException('Invalid Type');
56 }
57 }
58
59 public function each(Callable $function) : Collection
60 {
61 if ($this->isEmpty()) {
62 return $this;
63 }
64
65 array_map($function, $this->elements);
66
67 return $this;
68 }
69
70 public function map(Callable $function) : Collection
71 {
72 if ($this->isEmpty()) {
73 return clone $this;
74 }
75
76 $first = $function(reset($this->elements));
77 $mapped = Collection::of(get_class($first));
78 $mapped->append($first);
79
80 while ($object = next($this->elements)) {
81 $mapped->append($function($object));
82 }
83
84 return $mapped;
85 }
86
87 public function filter(Callable $function) : Collection
88 {
89 $filtered = Collection::of($this->getType());
90
91 if ($this->isEmpty()) {
92 return $filtered;
93 }
94
95 foreach ($this->elements as $element) {
96 if ($function($element)) {
97 $filtered->append($element);
98 }
99 }
100
101 return $filtered;
102 }
103
104 public function getBy(Callable $function)
105 {
106 if ($this->isEmpty()) {
107 throw new \UnderflowException('Collection is empty');
108 }
109 foreach ($this->elements as $element) {
110 if ($function($element)) {
111 return $element;
112 }
113 }
114 throw new \OutOfBoundsException('Element not found');
115 }
116
117 public function reduce(Callable $function, $initial)
118 {
119 if ($this->isEmpty()) {
120 return $initial;
121 }
122
123 foreach ($this->elements as $element) {
124 $initial = $function($element, $initial);
125 }
126
127 return $initial;
128 }
129
130 public function toArray(Callable $function = null) : array
131 {
132 if ($this->isEmpty()) {
133 return [];
134 }
135 if (!$function) {
136 return $this->elements;
137 }
138
139 return array_map($function, $this->elements);
140 }
141
142 public function getType() : string
143 {
144 return $this->type;
145 }
146
147 public function isEmpty() : bool
148 {
149 return !$this->count();
150 }
151
152 protected function isSupportedType($element) : bool
153 {
154 return is_a($element, $this->getType());
155 }
156 }
También podríamos refactorizar el test. Ahora que hemos creado algunos métodos de utilidad como isEmpty o getType, podemos cambiar algunos tests para emplearlos, de modo que sean más sencillos y más explícitos. También nos permiten eliminar las aserciones sobre propiedades privadas, que aunque se pueden hacer no deberían hacerse si es posible evitarlo.
A mí me ha quedado así:
1 <?php
2
3 namespace Test\Collections;
4
5 use Fi\Collections\Collection;
6 use PHPUnit\Framework\TestCase;
7
8 class CollectionTest extends TestCase
9 {
10 public function testShouldInitialize()
11 {
12 $this->assertInstanceOf(Collection::class, $this->getCollection());
13 }
14
15 private function getCollection() : Collection
16 {
17 return Collection::of(get_class($this));
18 }
19
20 public function testShouldBeConstructedEmpty()
21 {
22 $sut = $this->getCollection();
23 $this->assertEquals(0, $sut->count());
24 }
25
26 public function testShouldBeAbleToAppendOneElement()
27 {
28 $sut = $this->getCollection();
29 $sut->append($this);
30 $this->assertEquals(1, $sut->count());
31 }
32
33 public function testShouldBeAbleToAppendTwoElements()
34 {
35 $sut = $this->getCollection();
36 $sut->append($this);
37 $sut->append($this);
38 $this->assertEquals(2, $sut->count());
39 }
40
41 public function testShouldInitializeWithAType()
42 {
43 $sut = Collection::of(CollectionTest::class);
44 $this->assertInstanceOf(Collection::class, $sut);
45 }
46
47 public function testShouldNotStoreObjectOfIncorrectType()
48 {
49 $sut = $this->getCollection();
50 $this->expectException(\UnexpectedValueException::class);
51 $sut->append(new class
52 {
53 });
54 }
55
56 public function testShouldBeAbleToStoreSubClasses()
57 {
58 $sut = $this->getCollection();
59 $sut->append(new class extends CollectionTest
60 {
61 });
62 $this->assertEquals(1, $sut->count());
63 }
64
65 public function testEachShouldNotActOnEmptyCollection()
66 {
67 $sut = $this->getCollection();
68 $log = '';
69 $sut->each(function () use (&$log) {
70 $log .= '*';
71 });
72 $this->assertEquals('', $log);
73 }
74
75 public function testEachShouldIterateOverOneElement()
76 {
77 $sut = $this->getCollection();
78 $sut->append($this);
79 $log = '';
80 $sut->each(function () use (&$log) {
81 $log .= '*';
82 });
83 $this->assertEquals('*', $log);
84 }
85
86 public function testEachShouldIterateOverSeveralElements()
87 {
88 $sut = $this->getCollection();
89 $sut->append($this);
90 $sut->append($this);
91 $log = '';
92 $sut->each(function () use (&$log) {
93 $log .= '*';
94 });
95 $this->assertEquals('**', $log);
96 }
97
98 public function testEachShouldPassEveryElementToCallable()
99 {
100 $sut = $this->getCollection();
101 $sut->append($this);
102 $sut->append($this);
103 $log = '';
104 $sut->each(function (CollectionTest $element) use (&$log) {
105 $log .= '*';
106 });
107 $this->assertEquals('**', $log);
108 }
109
110 public function testEachShouldAllowPipelining()
111 {
112 $sut = $this->getCollection();
113 $sut->append($this);
114 $log = '';
115 $result = $sut->each(function (CollectionTest $element) use (&$log) {
116 $log .= '*';
117 });
118 $this->assertInstanceOf(Collection::class, $result);
119 }
120
121 public function testEachShouldAllowPipeliningOnEmptyCollection()
122 {
123 $sut = $this->getCollection();
124 $log = '';
125 $result = $sut->each(function (CollectionTest $element) use (&$log) {
126 $log .= '*';
127 });
128 $this->assertInstanceOf(Collection::class, $result);
129 }
130
131 public function testMapShouldAllowPipeliningOnEmptyCollection()
132 {
133 $sut = $this->getCollection();
134 $result = $sut->map(function (CollectionTest $element) {
135 return $element;
136 });
137 $this->assertInstanceOf(Collection::class, $result);
138 }
139
140 public function testMapShouldReturnEmptyCollectionWhenEmptyCollection()
141 {
142 $sut = $this->getCollection();
143 $result = $sut->map(function (CollectionTest $element) {
144 return $element;
145 });
146 $this->assertInstanceOf(Collection::class, $result);
147 $this->assertEquals(0, $result->count());
148 }
149
150 public function testMapShoulReturnAnotherCollection()
151 {
152 $sut = $this->getCollection();
153 $result = $sut->map(function (CollectionTest $element) {
154 return $element;
155 });
156 $this->assertNotSame($sut, $result);
157 }
158
159 public function testMapShouldMapOneElement()
160 {
161 $sut = $this->getCollection();
162 $sut->append($this);
163 $result = $sut->map(function (CollectionTest $element) {
164 return new MappedObject();
165 });
166 $this->assertEquals(MappedObject::class, $result->getType());
167 $this->assertEquals(1, $result->count());
168 }
169
170 public function testMapShouldMapSeveralElements()
171 {
172 $sut = $this->getCollection();
173 $sut->append($this);
174 $sut->append($this);
175 $result = $sut->map(function (CollectionTest $element) {
176 return new MappedObject();
177 });
178 $this->assertEquals(MappedObject::class, $result->getType());
179 $this->assertEquals(2, $result->count());
180 }
181
182 public function testFilterShouldReturnCollection()
183 {
184 $sut = $this->getCollection();
185 $result = $sut->filter(function (CollectionTest $element) {
186 return false;
187 });
188 $this->assertInstanceOf(Collection::class, $result);
189 }
190
191 public function testFilterShouldReturnAnotherCollection()
192 {
193 $sut = $this->getCollection();
194 $result = $sut->filter(function (CollectionTest $element) {
195 return false;
196 });
197 $this->assertNotSame($sut, $result);
198 }
199
200 public function testFilterShouldReturnAnotherCollectionWithTheSameType()
201 {
202 $sut = $this->getCollection();
203 $result = $sut->filter(function (CollectionTest $element) {
204 return false;
205 });
206 $this->assertEquals(CollectionTest::class, $result->getType());
207 }
208
209 public function testFilterShouldIncludeElementWhenCallableReturnTrue()
210 {
211 $sut = $this->getCollection();
212 $sut->append($this);
213 $result = $sut->filter(function (CollectionTest $element) {
214 return true;
215 });
216 $this->assertEquals(1, $result->count());
217 }
218
219 public function testFilterShouldNotIncludeElementWhenCallableReturnFalse()
220 {
221 $sut = $this->getCollection();
222 $sut->append($this);
223 $result = $sut->filter(function (CollectionTest $element) {
224 return false;
225 });
226 $this->assertEquals(0, $result->count());
227 }
228
229 public function testFilterShouldIterateOverAllElements()
230 {
231 $sut = $this->getCollection();
232 $sut->append($this);
233 $sut->append(clone $this);
234 $result = $sut->filter(function (CollectionTest $element) {
235 return true;
236 });
237 $this->assertEquals($sut, $result);
238 }
239
240 public function testGetByShouldFailWithExceptionWhenEmptyCollection()
241 {
242 $sut = $this->getCollection();
243 $this->expectException(\UnderflowException::class);
244 $sut->getBy(function (CollectionTest $element) {
245 return true;
246 });
247 }
248
249 public function testGetByShouldFailWithExceptionWhenElementNotFound()
250 {
251 $sut = $this->getCollection();
252 $sut->append($this);
253 $this->expectException(\OutOfBoundsException::class);
254 $sut->getBy(function (CollectionTest $element) {
255 return false;
256 });
257 }
258
259 public function testGetByShouldReturnFoundElement()
260 {
261 $sut = $this->getCollection();
262 $sut->append($this);
263 $result = $sut->getBy(function (CollectionTest $element) {
264 return true;
265 });
266 $this->assertSame($this, $result);
267 }
268
269 public function testGetByShouldReturnTheRightElement()
270 {
271 $sut = $this->getCollection();
272 $target = clone $this;
273 $target->target = true;
274 $sut->append($this);
275 $sut->append($target);
276 $result = $sut->getBy(function (CollectionTest $element) {
277 return $element->isTarget();
278 });
279 $this->assertSame($target, $result);
280 }
281
282 public function testReduceShouldReturnInitialValueWhenCollectionIsEmpty()
283 {
284 $sut = $this->getCollection();
285 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
286 return $accumulator + 1;
287 }, 0);
288 $this->assertEquals(0, $result);
289 }
290
291 public function testReduceInitialValueShouldAcceptAnyType()
292 {
293 $sut = $this->getCollection();
294 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
295 return $accumulator + 1;
296 }, "");
297 $this->assertEquals("", $result);
298 }
299
300 public function testReduceShouldApplyCallableToOneElement()
301 {
302 $sut = $this->getCollection();
303 $sut->append($this);
304 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
305 return $accumulator + 1;
306 }, 0);
307 $this->assertEquals(1, $result);
308 }
309
310 public function testReduceShouldApplyCallableToSeveralElements()
311 {
312 $sut = $this->getCollection();
313 $sut->append($this);
314 $sut->append($this);
315 $result = $sut->reduce(function (CollectionTest $element, $accumulator) {
316 return $accumulator + 1;
317 }, 0);
318 $this->assertEquals(2, $result);
319 }
320
321 public function testCollectShouldReturnInstanceOfCollection()
322 {
323 $sut = Collection::collect([
324 $this
325 ]);
326 $this->assertEquals(CollectionTest::class, $sut->getType());
327 }
328
329 public function testCollectShouldFailWithExceptionIfEmptyArray()
330 {
331 $this->expectException(\InvalidArgumentException::class);
332 Collection::collect([]);
333 }
334
335 public function testCollectShouldPopulateCollectionWithOneElement()
336 {
337 $sut = Collection::collect([
338 $this
339 ]);
340 $this->assertEquals(1, $sut->count());
341 }
342
343 public function testCollectShouldPopulateCollectionWithSeveralElements()
344 {
345 $sut = Collection::collect([
346 $this,
347 $this
348 ]);
349 $this->assertEquals(2, $sut->count());
350 }
351
352 public function testShouldFailWithExceptionsWhenInvalidTypeFound()
353 {
354 $this->expectException(\UnexpectedValueException::class);
355 Collection::collect([
356 $this,
357 new \stdClass()
358 ]);
359 }
360
361 public function testShouldMapEmptyCollectionToEmptyArray()
362 {
363 $sut = $this->getCollection();
364 $this->assertEquals([], $sut->toArray());
365 }
366
367 public function testShouldReturnCollectionAsArray()
368 {
369 $sample = [$this];
370 $sut = Collection::collect($sample);
371 $this->assertEquals($sample, $sut->toArray());
372 }
373
374 public function testShouldMapOneElementCollectionToArray()
375 {
376 $sut = $this->getCollection();
377 $sut->append($this);
378 $this->assertEquals(['mapped'], $sut->toArray(function(CollectionTest $eleme\
379 nt) {
380 return 'mapped';
381 }));
382 }
383
384 public function testShouldMapSeveralElementsCollectionToArray()
385 {
386 $sut = $this->getCollection();
387 $sut->append($this);
388 $sut->append($this);
389 $this->assertEquals(['mapped', 'mapped'], $sut->toArray(function(CollectionT\
390 est $element) {
391 return 'mapped';
392 }));
393 }
394
395 public function testShouldTellCollectionType()
396 {
397 $sut = Collection::of(CollectionTest::class);
398 $this->assertEquals(CollectionTest::class, $sut->getType());
399 }
400
401 public function testShouldTellIfCollectionIsEmpty()
402 {
403 $sut = $this->getCollection();
404 $this->assertTrue($sut->isEmpty());
405 }
406
407 public function testShouldTellIfCollectionIsNotEmpty()
408 {
409 $sut = $this->getCollection();
410 $sut->append($this);
411 $this->assertFalse($sut->isEmpty());
412 }
413
414 public function isTarget()
415 {
416 return isset($this->target);
417 }
418 }
419
420 class MappedObject
421 {
422
423 }