Spanish (Español) translation by Elías Nicolás (you can also view the original English article)
Prueba de controladores no es la cosa más fácil en el mundo. Bueno, déjame reformular eso: probarlos es muy facil; Lo que es difícil, al menos al principio, es determinar qué probar.
Should a controller test verify text on the page? ¿Debo tocar la base de datos? ¿Debe asegurarse de que existen variables en la vista? Si este es su primer paseo de heno, estas cosas pueden ser confusas! Déjame ayudar.
Las pruebas del controlador deben verificar las respuestas, asegurarse de que se activan los métodos correctos de acceso a la base de datos y afirmar que las variables de instancia apropiadas se envían a la vista.
El proceso de prueba de un controlador se puede dividir en tres partes.
- Aislar: Mock todas las dependencias (tal vez excluyendo
View
). - Llamar: Disparar el método del controlador deseado.
- Asegúrarse: Realizar afirmaciones, verificando que la etapa se ha ajustado correctamente.
El Hello World de la prueba del Controlador
La mejor manera de aprender estas cosas es a través de ejemplos. Aquí está el "hello world" de las pruebas de controladores en Laravel.
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/tests/controllers/PostsControllerTest.php
|
5 |
|
6 |
class PostsControllerTest extends TestCase { |
7 |
|
8 |
public function testIndex() |
9 |
{
|
10 |
$this->client->request('GET', 'posts'); |
11 |
}
|
12 |
|
13 |
}
|
Laravel
aprovecha un puñado de componentes Symfony para facilitar el proceso de
prueba de rutas y vistas, incluyendo HttpKernel, DomCrawler y
BrowserKit. Esta es la razón por la cual es primordial que sus pruebas PHPUnit heredan, no PHPUnit\_Framework\_TestCase
, pero TestCase
. No
te preocupes, Laravel todavía extiende la primera, pero ayuda a
configurar la aplicación de Laravel para la prueba, así como proporciona
una variedad de métodos de aserción de ayuda que te animan a usar. Más sobre eso en breve.
En el fragmento de código anterior, hacemos una solicitud GET
a /post
o localhost:8000/posts
. Suponiendo que esta línea se agregue a una nueva instalación de Laravel, Symfony lanzará una NotFoundHttpException
. Si está trabajando, intente ejecutar phpunit
desde la línea de comandos.
1 |
|
2 |
$ phpunit
|
3 |
1) PostsControllerTest::testIndex
|
4 |
Symfony\Component\HttpKernel\Exception\NotFoundHttpException: |
En la palabra humana, esto se traduce básicamente a, "Hey, traté de llamar a esa ruta, pero usted no tiene nada registrado, ¡tonto!"
Como
se puede imaginar, este tipo de solicitud es bastante común al punto de
que tiene sentido proporcionar un método auxiliar, como $this->call()
. De hecho, ¡Laravel hace eso mismo! Esto significa que el ejemplo anterior puede ser refactorizado, así:
1 |
|
2 |
#app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testIndex() |
5 |
{
|
6 |
$this->call('GET', 'posts'); |
7 |
}
|
La sobrecarga es su amigo
Aunque
seguiremos con la funcionalidad básica en este capítulo, en mis
proyectos personales, voy más allá al permitir métodos como $this->get()
, $this->post()
, etc. Gracias a PHP sobrecarga, esto sólo requiere la adición de un único método, que se podría añadir a app/tests/TestCase.php
.
1 |
|
2 |
# app/tests/TestCase.php
|
3 |
|
4 |
public function __call($method, $args) |
5 |
{
|
6 |
if (in_array($method, ['get', 'post', 'put', 'patch', 'delete'])) |
7 |
{
|
8 |
return $this->call($method, $args[0]); |
9 |
}
|
10 |
|
11 |
throw new BadMethodCallException; |
12 |
}
|
Ahora, eres libre de escribir $this->get('posts')
y lograr el mismo resultado que los dos ejemplos anteriores. Como se señaló anteriormente, sin embargo, vamos a seguir con la funcionalidad base del marco para la simplicidad.
Para hacer pasar la prueba, sólo tenemos que preparar la ruta adecuada.
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/routes.php
|
5 |
|
6 |
Route::get('posts', function() |
7 |
{
|
8 |
return 'all posts'; |
9 |
});
|
Ejecutar phpunit
de nuevo nos devolverá verde.
Assertions de Laravel Helper
Una
prueba que te encontrarás escribiendo repetidamente es aquel que
asegura que un controlador pasa una variable particular a una vista. Por ejemplo, el método de index
de PostsController
debe pasar una variable $posts
a su vista asociada, ¿verdad? De esta forma, la vista puede filtrar todos los mensajes y mostrarlos en la página. Esta es una prueba importante para escribir!
Si es que una tarea común, entonces, una vez más, ¿no tendría sentido para Laravel para proporcionar una afirmación de ayuda para lograr esto mismo? Por supuesto. ¡Y, por supuesto, a Laravel tambien!
Illuminate\Foundation\Testing\TestCase
incluye una serie de métodos que
reducirán drásticamente la cantidad de código necesaria para realizar
aserciones básicas. Esta lista incluye:
assertViewHas
assertResponseOk
assertRedirectedTo
assertRedirectedToRoute
assertRedirectedToAction
assertSessionHas
assertSessionHasErrors
Los siguientes ejemplos llaman a GET /posts
y verifica que sus vistas reciben la variable, $posts
.
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testIndex() |
5 |
{
|
6 |
$this->call('GET', 'posts'); |
7 |
|
8 |
$this->assertViewHas('posts'); |
9 |
}
|
Sugerencia: Cuando se trata de formatear, prefiero proporcionar un salto de línea entre la afirmación de una prueba y el código que prepara la etapa.
AssertViewHas
es simplemente un poco de azúcar que inspecciona el objeto de respuesta
- que se devuelve desde $this->call()
- y verifica que los datos
asociados con la vista contienen una variable posts
.
Al inspeccionar el objeto de respuesta, tiene dos opciones principales.
-
$response->getOriginalContent()
: busca el contenido original o la devueltaView
. Opcionalmente, puede acceder a la propiedadoriginal
directamente, en lugar de llamar al métodogetOriginalContent
. -
$Response->getContent()
: Obtiene la salida renderizada. Si se devuelve una instanciaView
de la ruta, entoncesgetContent()
será igual a la salida HTML. Esto puede ser útil para verificaciones de DOM, como "la vista debe contener esta cadena".
Supongamos que la ruta de posts
consta de:
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/routes.php
|
5 |
|
6 |
Route::get('posts', function() |
7 |
{
|
8 |
return View::make('posts.index'); |
9 |
});
|
Deberíamos ejecutar phpunit
, se fallara con un útil paso siguiente mensaje:
1 |
|
2 |
1) PostsControllerTest::testIndex
|
3 |
Failed asserting that an array has the key 'posts'.
|
Para hacerlo verde, simplemente buscamos los posts y lo pasamos a la vista.
1 |
|
2 |
|
3 |
# app/routes.php
|
4 |
|
5 |
Route::get('posts', function() |
6 |
{
|
7 |
$posts = Post::all(); |
8 |
|
9 |
return View::make('posts.index', ['posts', $posts]); |
10 |
});
|
Una cosa a tener en cuenta es que, como el código actual, sólo asegura que la variable, $posts
, se pasa a la vista. No inspecciona su valor. El assertViewHas
opcionalmente acepta un segundo argumento para verificar el valor de la variable, así como su existencia.
1 |
|
2 |
|
3 |
# app/tests/controllers/PostsControllerTest.php
|
4 |
|
5 |
public function testIndex() |
6 |
{
|
7 |
$this->call('GET', 'posts'); |
8 |
|
9 |
$this->assertViewHas('posts', 'foo'); |
10 |
}
|
Con este código modificado, a menos que la vista tenga una variable, $posts
, que sea igual a foo
, la prueba fallará. En
esta situación, sin embargo, es probable que prefieramos no especificar
un valor, sino que declaramos que el valor es una instancia de la clase Illuminate\Database\Eloquent\Collection
de Laravel. ¿Cómo podríamos lograr eso? PHPUnit proporciona una afirmación assertInstanceOf
útil para llenar esta misma necesidad!
1 |
|
2 |
|
3 |
# app/tests/controllers/PostsControllerTest.php
|
4 |
|
5 |
public function testIndex() |
6 |
{
|
7 |
$response = $this->call('GET', 'posts'); |
8 |
|
9 |
$this->assertViewHas('posts'); |
10 |
|
11 |
// getData() returns all vars attached to the response.
|
12 |
$posts = $response->original->getData()['posts']; |
13 |
|
14 |
$this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $posts); |
15 |
}
|
Con
esta modificación, hemos declarado que el controlador debe pasar $posts
- una instancia de Illuminate\Database\ Eloquent\Collection
-
a la vista. Excelente.
Mocking la base de datos
Hay un problema flagrante con nuestras pruebas hasta ahora. ¿Lo atrapaste?
Para cada prueba, se está ejecutando una consulta SQL en la base de datos. Aunque esto es útil para ciertos tipos de pruebas (aceptación, integración), para las pruebas básicas del controlador, sólo servirá para disminuir el rendimiento.
He perforado esto en su cráneo varias veces en este momento. No estamos interesados en probar la capacidad de Eloquent de buscar registros de una base de datos. Tiene sus propias pruebas. ¡Taylor sabe que funciona! No perdamos tiempo y poder de procesamiento repitiendo las mismas pruebas.
En
su lugar, lo mejor es burlarse de la base de datos, y simplemente
verificar que los métodos apropiados se llaman con los argumentos
correctos. O, en otras palabras, queremos asegurarnos de que Post::all()
nunca se dispara y llega a la base de datos. Sabemos que funciona, por lo que no requiere pruebas.
Esta sección dependerá en gran medida de la biblioteca Mockery. Por favor revise ese capítulo de mi libro, si aún no está familiarizado con él.
Refactorización Requerida
Lamentablemente, hasta ahora, hemos estructurado el código de una manera que hace prácticamente imposible probar.
1 |
|
2 |
# app/routes.php
|
3 |
|
4 |
Route::get('posts', function() |
5 |
{
|
6 |
// Ouch. We can't test this!!
|
7 |
$posts = Post::all(); |
8 |
|
9 |
return View::make('posts.index') |
10 |
->with('posts', $posts); |
11 |
});
|
Esta es precisamente la razón por la que se considera mala práctica anidar llamadas de Eloquent en sus controladores. No
confundas las fachadas de Laravel, que son comprobables y pueden
intercambiarse con mocks (Queue::shouldReceive()
), con tus modelos
Eloquent. La solución es inyectar la capa de base de datos en el controlador a través del constructor. Esto requiere una cierta refactorización.
Advertencia: almacenar la lógica dentro de las devoluciones de llamada de ruta es útil para proyectos pequeños y API, pero hacen que las pruebas sean increíblemente difíciles. Para aplicaciones de cualquier tamaño considerable, utilice controladores.
Registremos un nuevo recurso reemplazando la ruta de posts
con:
1 |
|
2 |
# app/routes.php
|
3 |
|
4 |
Route::resource('posts', 'PostsController'); |
... y crear el controlador de recursos necesarios con Artisan.
1 |
|
2 |
$ php artisan controller:make PostsController
|
3 |
Controller created successfully! |
Ahora, en lugar de referenciar directamente el modelo Post
, lo inyectaremos en el constructor del controlador. He
aquí un ejemplo condensado que omite todos los métodos de restful,
excepto el que estamos actualmente interesados en las pruebas.
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/controllers/PostsController.php
|
5 |
|
6 |
class PostsController extends BaseController { |
7 |
|
8 |
protected $post; |
9 |
|
10 |
public function __construct(Post $post) |
11 |
{
|
12 |
$this->post = $post; |
13 |
}
|
14 |
|
15 |
public function index() |
16 |
{
|
17 |
$posts = $this->post->all(); |
18 |
|
19 |
return View::make('posts.index') |
20 |
->with('posts', $posts); |
21 |
}
|
22 |
|
23 |
}
|
Tenga en cuenta que es una mejor idea escribir la sugerencia de una interfaz, en lugar de hacer referencia al modelo Eloquent, en sí. Pero, ¡una cosa a la vez! Vamos a trabajar hasta eso.
Esta es una manera significativamente mejor de estructurar el código. Debido a que el modelo ahora se inyecta, tenemos la capacidad de intercambiarlo con una versión simulada para la prueba. He aquí un ejemplo de hacer eso:
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/tests/controllers/PostsControllerTest.php
|
5 |
|
6 |
class PostsControllerTest extends TestCase { |
7 |
|
8 |
public function __construct() |
9 |
{
|
10 |
// We have no interest in testing Eloquent
|
11 |
$this->mock = Mockery::mock('Eloquent', 'Post'); |
12 |
}
|
13 |
|
14 |
public function tearDown() |
15 |
{
|
16 |
Mockery::close(); |
17 |
}
|
18 |
|
19 |
public function testIndex() |
20 |
{
|
21 |
$this->mock |
22 |
->shouldReceive('all') |
23 |
->once() |
24 |
->andReturn('foo'); |
25 |
|
26 |
$this->app->instance('Post', $this->mock); |
27 |
|
28 |
$this->call('GET', 'posts'); |
29 |
|
30 |
$this->assertViewHas('posts'); |
31 |
}
|
32 |
|
33 |
}
|
El principal beneficio de esta reestructuración es que, ahora, la base de datos nunca será usada innecesariamente. En su lugar, usando Mockery, simplemente verificamos que el método all
se dispara en el modelo.
1 |
|
2 |
$this->mock |
3 |
->shouldReceive('all') |
4 |
->once(); |
Por
desgracia, si elige renunciar a la codificación a una interfaz, y en su
lugar inyectar el modelo Post
en el controlador, un poco de trucos
tiene que ser utilizados con el fin de evitar el uso de Eloquent de la
estática, que puede chocar con Mockery. Esta
es la razón por la que hijack tanto el Post
y Eloquent
clases
dentro del constructor de la prueba, antes de las versiones oficiales se
han cargado. De esta manera, tenemos una lista limpia para declarar cualquier expectativa. La
desventaja, por supuesto, es que no podemos usar ningún método
existente, a través del uso de métodos de Mockery, como makePartial()
.
El contenedor de IoC
El contenedor IoC de Laravel facilita drásticamente el proceso de inyección de dependencias en sus clases. Cada vez que se solicita un controlador, se resuelve fuera del contenedor de IoC. Como
tal, cuando necesitamos declarar que una versión simulada de Post
se
debe utilizar para la prueba, sólo tenemos que proporcionar a Laravel la
instancia de Post
que debe utilizarse.
1 |
|
2 |
$this->app->instance('Post', $this->mock); |
Piense en este código como diciendo: "Hey Laravel, cuando necesitas una instancia de
Post
, quiero que uses mi versión mock." Debido a que la aplicación extiende elContainer
, tenemos acceso a todos los métodos de IoC directamente fuera de él.
Tras la instanciación del controlador, Laravel aprovecha la potencia de la reflexión de PHP para leer el tipo e inyectar la dependencia para usted. Está bien; Usted no tiene que escribir un solo enlace para permitir esto; ¡Es automatizado!
Redirecciones
Otra expectativa común de que te encontrarás escribiendo es aquella que asegura que el usuario sea redirigido a la ubicación correcta, tal vez al agregar una nueva entrada a la base de datos. ¿Cómo podemos lograr esto?
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testStore() |
5 |
{
|
6 |
$this->mock |
7 |
->shouldReceive('create') |
8 |
->once(); |
9 |
|
10 |
$this->app->instance('Post', $this->mock); |
11 |
|
12 |
$this->call('POST', 'posts'); |
13 |
|
14 |
$this->assertRedirectedToRoute('posts.index'); |
15 |
|
16 |
}
|
Suponiendo
que estamos siguiendo un sabor restful, para agregar un nuevo post, hacemos
POST
a la colección, o posts
(no confunda el método de solicitud POST
con el nombre del recurso, que sólo tiene el mismo nombre).
1 |
|
2 |
$this->call('POST', 'posts'); |
Entonces, sólo necesitamos aprovechar otra de las aserciones de ayuda de Laravel, assertRedirectedToRoute
.
Sugerencia: Cuando un recurso está registrado con Laravel (
Route::resource()
), el framework registrará automáticamente las rutas nombradas necesarias. Ejecutephp artisan routes
si alguna vez se olvida de cuáles son estos nombres.
Es posible que prefiera también asegurarse de que el $_POST
superglobal se pasa al método create
. A
pesar de que no estamos enviando físicamente un formulario, todavía
podemos permitir esto, a través del método Input::replace()
, que nos
permite "stub" esta array. Aquí
está la prueba modificada, que utiliza el método with()
de Mockery
para verificar los argumentos pasados al método al que hace referencia
shouldReceive
.
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testStore() |
5 |
{
|
6 |
Input::replace($input = ['title' => 'My Title']);</p> |
7 |
|
8 |
$this->mock |
9 |
->shouldReceive('create') |
10 |
->once() |
11 |
->with($input); |
12 |
|
13 |
$this->app->instance('Post', $this->mock); |
14 |
|
15 |
$this->call('POST', 'posts'); |
16 |
|
17 |
$this->assertRedirectedToRoute('posts.index'); |
18 |
}
|
Rutas
Una cosa que no hemos considerado en esta prueba es la validación. Debe haber dos caminos separados a través del método store
, dependiendo de si la validación pasa:
- Redirigir de nuevo al formulario "Crear publicación" y mostrar los errores de validación de formulario.
- Redirigir a la colección, o la ruta nombrada,
posts.index
.
Como una práctica recomendada, cada prueba debe representar un solo camino a través de su código.
Esta primera ruta será para validación fallida.
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testStoreFails() |
5 |
{
|
6 |
// Set stage for a failed validation
|
7 |
Input::replace(['title' => '']); |
8 |
|
9 |
$this->app->instance('Post', $this->mock); |
10 |
|
11 |
$this->call('POST', 'posts'); |
12 |
|
13 |
// Failed validation should reload the create form
|
14 |
$this->assertRedirectedToRoute('posts.create'); |
15 |
|
16 |
// The errors should be sent to the view
|
17 |
$this->assertSessionHasErrors(['title']); |
18 |
}
|
El fragmento de código anterior declara explícitamente qué errores deben existir. Alternativamente,
puede omitir el argumento a assertSessionHasErrors
, en cuyo caso se
limitará a verificar que una bolsa de mensajes ha sido destellada (en la
traducción, su redirección incluye withErrors($errors)
).
Ahora, para la prueba que maneja la validación exitosa.
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testStoreSuccess() |
5 |
{
|
6 |
// Set stage for successful validation
|
7 |
Input::replace(['title' => 'Foo Title']);</p> |
8 |
|
9 |
$this->mock |
10 |
->shouldReceive('create') |
11 |
->once(); |
12 |
|
13 |
$this->app->instance('Post', $this->mock); |
14 |
|
15 |
$this->call('POST', 'posts'); |
16 |
|
17 |
// Should redirect to collection, with a success flash message
|
18 |
$this->assertRedirectedToRoute('posts.index', ['flash']); |
19 |
}
|
El código de producción para estas dos pruebas podría ser:
1 |
|
2 |
# app/controllers/PostsController.php
|
3 |
|
4 |
public function store() |
5 |
{
|
6 |
$input = Input::all(); |
7 |
|
8 |
// We'll run validation in the controller for convenience
|
9 |
// You should export this to the model, or a service
|
10 |
$v = Validator::make($input, ['title' => 'required']); |
11 |
|
12 |
if ($v->fails()) |
13 |
{
|
14 |
return Redirect::route('posts.create') |
15 |
->withInput() |
16 |
->withErrors($v->messages()); |
17 |
}
|
18 |
|
19 |
$this->post->create($input); |
20 |
|
21 |
return Redirect::route('posts.index') |
22 |
->with('flash', 'Your post has been created!'); |
23 |
}
|
Observe cómo el Validator está anidado directamente en el controlador? En general, te recomiendo que abstraigas esto a un servicio. De esta forma, puede probar su validación de forma aislada de cualquier controlador o ruta. Sin embargo, dejemos las cosas como están por la simplicidad. Una cosa a tener en cuenta es que no estamos mock al Validator
, aunque ciertamente podría hacerlo. Debido
a que esta clase es una fachada, se puede intercambiar fácilmente con
una versión burlada, a través del método shouldReceive
de la Facade, sin
que tengamos que preocuparnos por inyectar una instancia a través del
constructor. ¡Victoria!
1 |
|
2 |
# app/controllers/PostsController.php
|
3 |
|
4 |
Validator::shouldReceive('make') |
5 |
->once() |
6 |
->andReturn(Mockery::mock(['fails' => 'true'])); |
De vez en cuando, usted encontrará que un método que necesita ser mock debe devolver un objeto, por sí mismo. Por suerte, con Mockery, esto es un muy facil: sólo necesitamos crear un simulacro anónimo, y pasar una array , que señala el nombre del método y el valor de respuesta, respectivamente. Como tal:
1 |
|
2 |
Mockery::mock(['fails' => 'true']) |
Preparará un objeto, que contiene un método fails()
que devuelve true
.
Repositorios
Para permitir una flexibilidad óptima, en lugar de crear un enlace directo entre su controlador y un ORM, como Eloquent, es mejor codificar en una interfaz. La ventaja considerable de este enfoque es que, si tal vez necesitas intercambiar Eloquente por, digamos, Mongo o Redis, hacerlo literalmente requiere la modificación de una sola línea. Incluso mejor, el controlador no necesita ser tocado nunca.
Los repositorios representan la capa de acceso a datos de su aplicación.
¿Qué aspecto podría tener una interfaz para administrar la capa de base de datos de un Post
? Esto debería hacerlo empezar.
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/repositories/PostRepositoryInterface.php
|
5 |
|
6 |
interface PostRepositoryInterface { |
7 |
|
8 |
public function all(); |
9 |
|
10 |
public function find($id); |
11 |
|
12 |
public function create($input); |
13 |
|
14 |
}
|
Esto ciertamente puede ser ampliado, pero hemos añadido los métodos mínimos para la demostración: all
, find
y create
. Observe que las interfaces del repositorio se almacenan en app/repositories
. Debido a
que esta carpeta no se carga automáticamente de forma predeterminada,
necesitamos actualizar el archivo composer.json
para que la aplicación
le haga referencia.
1 |
|
2 |
// composer.json
|
3 |
|
4 |
"autoload": { |
5 |
"classmap": [ |
6 |
// ....
|
7 |
"app/repositories" |
8 |
]
|
9 |
}
|
Cuando se añade una nueva clase a este directorio, no olvides al
composer dump-autoload -o
El indicador-o
, (optimize) es opcional, pero siempre debe usarse, como una mejor práctica.
Si intenta inyectar esta interfaz en su controlador, Laravel fallara Adelante; Pruebalo y vea. Aquí está el PostController
modificado, que se ha actualizado para inyectar una interfaz, en lugar del modelo Post
Eloquent.
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/controllers/PostsController.php
|
5 |
|
6 |
use Repositories\PostRepositoryInterface as Post; |
7 |
|
8 |
class PostsController extends BaseController { |
9 |
|
10 |
protected $post; |
11 |
|
12 |
public function __construct(Post $post) |
13 |
{
|
14 |
$this->post = $post; |
15 |
}
|
16 |
|
17 |
public function index() |
18 |
{
|
19 |
$posts = $this->post->all(); |
20 |
|
21 |
return View::make('posts.index', ['posts' => $posts]); |
22 |
}
|
23 |
|
24 |
}
|
Si ejecuta el servidor y ve la salida, se encontrará con la temida (pero hermosa) página de error Whoops , declarando que "PostRepositoryInterface no es instanciable."



Si usted piensa en ello, por supuesto, ¡el marco esta fallando! Laravel es inteligente, pero no es un lector mental. Se necesita saber qué implementación de la interfaz debe utilizarse dentro del controlador.
Por ahora, vamos a agregar este enlace a app/routes.php
. Posteriormente, en su lugar utilizaremos proveedores de servicios para almacenar este tipo de lógica.
1 |
|
2 |
# app/routes.php
|
3 |
|
4 |
App::bind( |
5 |
'Repositories\PostRepositoryInterface', |
6 |
'Repositories\EloquentPostRepository'
|
7 |
);
|
Verbalize
esta llamada de función como, "Laravel, bebé, cuando necesitas una
instancia de PostRepositoryInterface
, quiero que uses
EloquentPostRepository
."
App/repositories/EloquentPostRepository
simplemente será una envoltura
alrededor de Eloquent que implementa PostRepositoryInterface
. De esta manera, no estamos restringiendo la API (y cualquier otra implementación) a la interpretación de Eloquent; Podemos nombrar los métodos como quisiéramos.
1 |
|
2 |
<?php namespace Repositories; |
3 |
|
4 |
# app/repositories/EloquentPostRepository.php
|
5 |
|
6 |
use Repositories\PostRepositoryInterface; |
7 |
use Post; |
8 |
|
9 |
class EloquentPostRepository implements PostRepositoryInterface { |
10 |
|
11 |
public function all() |
12 |
{
|
13 |
return Post::all(); |
14 |
}
|
15 |
|
16 |
public function find($id) |
17 |
{
|
18 |
return Post::find($id); |
19 |
}
|
20 |
|
21 |
public function create($input) |
22 |
{
|
23 |
return Post::create($input); |
24 |
}
|
25 |
|
26 |
}
|
Algunos podrían argumentar que el modelo
Post
debería ser inyectado en esta implementación con fines de testabilidad. Si usted está de acuerdo, simplemente inyectarlo a través del constructor, como habitualmente.
Eso es todo lo que debe tomar hacerlo! Actualiza el navegador y las cosas deberían volver a la normalidad. Sólo ahora su aplicación está mucho mejor estructurada y el controlador ya no está vinculado a Eloquent.
Imaginemos que, dentro de unos meses, su jefe le informará que necesita intercambiar Eloquent con Redis. Pues
bien, ya que ha estructurado su aplicación de esta manera a prueba de
futuro, sólo necesita crear la nueva app/repositories/RedisPostRepository
:
1 |
|
2 |
<?php namespace Repositories; |
3 |
|
4 |
# app/repositories/RedisPostRepository.php
|
5 |
|
6 |
use Repositories\PostRepositoryInterface; |
7 |
|
8 |
class RedisPostRepository implements PostRepositoryInterface { |
9 |
|
10 |
public function all() |
11 |
{
|
12 |
// return all with Redis
|
13 |
}
|
14 |
|
15 |
public function find($id) |
16 |
{
|
17 |
// return find one with Redis
|
18 |
}
|
19 |
|
20 |
public function create($input) |
21 |
{
|
22 |
// return create with Redis
|
23 |
}
|
24 |
|
25 |
}
|
Y actualizar el enlace:
1 |
|
2 |
# app/routes.php
|
3 |
|
4 |
App::bind( |
5 |
'Repositories\PostRepositoryInterface', |
6 |
'Repositories\RedisPostRepository'
|
7 |
);
|
Al instante, ahora está aprovechando Redis en su controlador. ¿Noto cómo app/controllers/PostsController.php
nunca se tocó? Esa es la belleza de la misma!
Estructura
Hasta ahora en esta lección, nuestra organización ha sido un poco carente. IoC enlaces en el archivo routes.php
? Todos los repositorios agrupados en un directorio? Claro, eso puede funcionar al principio, pero, muy rápidamente, se hará evidente que esto no escala.
En la sección final de este artículo, PSR-er nuestro código, y aprovechar los proveedores de servicios para registrar cualquier consolidación aplicable.
PSR-0 define los requisitos obligatorios que deben cumplirse para la interoperabilidad del cargador automático.
Un cargador PSR-0 puede estar registrado con Composer, a través del objeto psr-0
.
1 |
|
2 |
// composer.json
|
3 |
|
4 |
"autoload": { |
5 |
"psr-0": { |
6 |
"Way": "app/lib/" |
7 |
}
|
8 |
}
|
La sintaxis puede ser confusa al principio. Ciertamente fue para mí. Una
manera fácil de descifrar "Way": "app/lib/"
es pensar en ti mismo,
"La carpeta base para el espacio de nombres Way
se encuentra en app/lib
." Por supuesto, reemplazar mi apellido con el nombre de su proyecto. La estructura de directorios para que coincida con esto sería:
- app/
- lib/
- Way/
A
continuación, en lugar de agrupar todos los repositorios en un
directorio de repositories
, un enfoque más elegante podría ser
clasificarlos en varios directorios, de la siguiente manera:
- app/
- lib/
- Way/
- Storage/
- Post/
- PostRepositoryInterface.php
- EloquentPostRepository.php
Es
vital que nos adherimos a esta convención de nomenclatura y carpeta, si
queremos que el autoloading funcione como se espera. Lo único que queda por hacer es actualizar los espacios de nombres para PostRepositoryInterface
y EloquentPostRepository
.
1 |
|
2 |
<?php namespace Way\Storage\Post; |
3 |
|
4 |
# app/lib/Way/Storage/Post/PostRepositoryInterface.php
|
5 |
|
6 |
interface PostRepositoryInterface { |
7 |
|
8 |
public function all(); |
9 |
|
10 |
public function find($id); |
11 |
|
12 |
public function create($input); |
13 |
|
14 |
}
|
Y para la implementación:
1 |
|
2 |
<?php namespace Way\Storage\Post; |
3 |
|
4 |
# app/lib/Way/Storage/Post/EloquentPostRepository.php
|
5 |
|
6 |
use Post; |
7 |
|
8 |
class EloquentPostRepository implements PostRepositoryInterface { |
9 |
|
10 |
public function all() |
11 |
{
|
12 |
return Post::all(); |
13 |
}
|
14 |
|
15 |
public function find($id) |
16 |
{
|
17 |
return Post::find($id); |
18 |
}
|
19 |
|
20 |
public function create($input) |
21 |
{
|
22 |
return Post::create($input); |
23 |
}
|
24 |
|
25 |
}
|
Aquí vamos; Eso es mucho más limpio. Pero, ¿qué pasa con esas molestas fijaciones? El archivo de rutas puede ser un lugar conveniente para experimentar, pero tiene poco sentido almacenar allí permanentemente. En su lugar, utilizaremos los proveedores de servicios.
Los proveedores de servicios no son nada más que las clases de arranque que se pueden utilizar para hacer lo que quieras: registrar un enlace, conectar un evento, importar un archivo de rutas, etc.
El registro de un proveedor de servicios () será activado automáticamente por Laravel.
1 |
|
2 |
<?php namespace Way\Storage; |
3 |
|
4 |
# app/lib/Way/Storage/StorageServiceProvider.php
|
5 |
|
6 |
use Illuminate\Support\ServiceProvider; |
7 |
|
8 |
class StorageServiceProvider extends ServiceProvider { |
9 |
|
10 |
// Triggered automatically by Laravel
|
11 |
public function register() |
12 |
{
|
13 |
$this->app->bind( |
14 |
'Way\Storage\Post\PostRepositoryInterface', |
15 |
'Way\Storage\Post\EloquentPostRepository'
|
16 |
);
|
17 |
}
|
18 |
|
19 |
}
|
Para
que este archivo sea conocido por Laravel, sólo es necesario incluirlo
en app/config/app.php
, dentro de la array de providers
.
1 |
|
2 |
# app/config/app.php
|
3 |
|
4 |
'providers' => array( |
5 |
'Illuminate\Foundation\Providers\ArtisanServiceProvider', |
6 |
'Illuminate\Auth\AuthServiceProvider', |
7 |
// ...
|
8 |
'Way\Storage\StorageServiceProvider'
|
9 |
)
|
Bueno; Ahora tenemos un archivo dedicado para registrar nuevos enlaces.
Actualización de las pruebas
Con
nuestra nueva estructura en su lugar, en lugar de mock del modelo
Eloquent, en sí, en lugar de eso podemos simular
PostRepositoryInterface
. He aquí un ejemplo de una prueba de este tipo:
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function testIndex() |
5 |
{
|
6 |
$mock = Mockery::mock('Way\Storage\Post\PostRepositoryInterface'); |
7 |
$mock->shouldReceive('all')->once(); |
8 |
|
9 |
$this->app->instance('Way\Storage\Post\PostRepositoryInterface', $mock); |
10 |
|
11 |
$this->call('GET', 'posts'); |
12 |
|
13 |
$this->assertViewHas('posts'); |
14 |
}
|
Sin embargo, podemos mejorar esto. Es lógico que cada método dentro de PostsControllerTest
necesitará una versión mock del repositorio. Como tal, es mejor extraer parte de este trabajo de preparación en su propio método, así:
1 |
|
2 |
# app/tests/controllers/PostsControllerTest.php
|
3 |
|
4 |
public function setUp() |
5 |
{
|
6 |
parent::setUp(); |
7 |
|
8 |
$this->mock('Way\Storage\Post\PostRepositoryInterface'); |
9 |
}
|
10 |
|
11 |
public function mock($class) |
12 |
{
|
13 |
$mock = Mockery::mock($class); |
14 |
|
15 |
$this->app->instance($class, $mock); |
16 |
|
17 |
return $mock; |
18 |
}
|
19 |
|
20 |
public function testIndex() |
21 |
{
|
22 |
$this->mock->shouldReceive('all')->once(); |
23 |
|
24 |
$this->call('GET', 'posts'); |
25 |
|
26 |
$this->assertViewHas('posts'); |
27 |
}
|
No está mal, ¿hey?
Ahora, si quieres ser super-liviano, y estás dispuesto a agregar un toque de lógica de prueba a tu código de producción, ¡incluso podrías realizar tu mock dentro del modelo Eloquent! Esto permitiría:
1 |
|
2 |
Post::shouldReceive('all')->once(); |
Detrás de las escenas, esto mock PostRepositoryInterface, y actualizara la vinculación IoC. No se puede obtener mucho más legible que eso!
Permitir
esta sintaxis sólo requiere actualizar el modelo Post
o, mejor, un BaseModel
que todos los modelos Eloquent se extienden. He aquí un ejemplo de lo anterior:
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/models/Post.php
|
5 |
|
6 |
class Post extends Eloquent { |
7 |
|
8 |
public static function shouldReceive() |
9 |
{
|
10 |
$class = get_called_class(); |
11 |
$repo = "Way\\Storage\\{$class}\\{$class}RepositoryInterface"; |
12 |
$mock = Mockery::mock($repo); |
13 |
|
14 |
App::instance($repo, $mock); |
15 |
|
16 |
return call_user_func_array([$mock, 'shouldReceive'], func_get_args()); |
17 |
}
|
18 |
|
19 |
}
|
Si puede gestionar la batalla interna "Debería incrustar lógica de prueba en código de producción", verá que esto permite pruebas significativamente más legibles.
1 |
|
2 |
<?php
|
3 |
|
4 |
# app/tests/controllers/PostsControllerTest.php
|
5 |
|
6 |
class PostsControllerTest extends TestCase { |
7 |
|
8 |
public function tearDown() |
9 |
{
|
10 |
Mockery::close(); |
11 |
}
|
12 |
|
13 |
public function testIndex() |
14 |
{
|
15 |
Post::shouldReceive('all')->once(); |
16 |
|
17 |
$this->call('GET', 'posts'); |
18 |
|
19 |
$this->assertViewHas('posts'); |
20 |
}
|
21 |
|
22 |
public function testStoreFails() |
23 |
{
|
24 |
Input::replace($input = ['title' => '']); |
25 |
|
26 |
$this->call('POST', 'posts'); |
27 |
|
28 |
$this->assertRedirectedToRoute('posts.create'); |
29 |
$this->assertSessionHasErrors(); |
30 |
}
|
31 |
|
32 |
public function testStoreSuccess() |
33 |
{
|
34 |
Input::replace($input = ['title' => 'Foo Title']); |
35 |
|
36 |
Post::shouldReceive('create')->once(); |
37 |
|
38 |
$this->call('POST', 'posts'); |
39 |
|
40 |
$this->assertRedirectedToRoute('posts.index', ['flash']); |
41 |
}
|
42 |
|
43 |
}
|
Se siente bien, ¿no? Esperemos que este artículo no ha sido demasiado abrumador. La clave es aprender a organizar sus repositorios de tal manera que sean lo más fáciles de simular e inyectar en sus controladores. ¡Como resultado de ese esfuerzo, sus pruebas serán rápidas!
Este artículo es un extracto de mi próximo libro, Laravel Testing Decoded. ¡Manténgase atento para su lanzamiento en mayo de 2013!