text
y uno de tipo select
para
crear un nuevo elemento de tipo moneda. Para esto realizaremos 3 tareas:
"If I had a dime for every time I've seen someone use FLOAT to store currency, I'd have $999.997634" -- Bill Karwin
Supongamos que tenemos nuevamente nuestra aplicación de catálogo de productos y que
estamos usando el patrón de diseño de moneda para los precios de los productos
en lugar de usar valores de tipo float
, porque ahora tenemos productos que podemos
comprar y vender en dólares y en pesos.
Nuestro objetivo es crear un formulario que nos permita editar la información de los precios de compra y venta de nuestros productos como se muestra en la siguiente imagen:
Nuestro primer paso es crear un elemento que extienda de EasyForms\Elements\Element
compuesto a su vez por otros dos elementos amount
y currency
del tipo
EasyForms\Elements\Text
y EasyForms\Elements\Select
respectivamente.
use EasyForms\Elements\Element;
use EasyForms\Elements\Select;
use EasyForms\Elements\Text;
class Money extends Element
{
protected $amount;
protected $currency;
public function __construct($name)
{
parent::__construct($name);
$this->amount = new Text("{$name}[amount]");
$this->currency = new Select("{$name}[currency]");
}
}
Los valores de nuestro elemento serán recuperados en forma de un arreglo con las
llaves amount
y currency
.
Al ser un elemento compuesto, debemos modificar la forma en que se manipula su
valor, por lo que tenemos que sobrecargar los métodos setValue
y
value
.
setValue
debe tomar el arreglo que viene de alguna de las superglobales
$_GET
o $_POST
y pasar el valor correspondiente llamando al método setValue
de los elementos amount
y currency
respectivamente.value
, por el contrario, debe recuperar el valor de los elementos amount
y
currency
llamando al método value
en cada objeto, a fin de devolver el arreglo
original que recibió de alguna de las variables superglobales.// ...
class Money extends Element
{
// ...
public function setValue($value)
{
$this->amount->setValue($value['amount']);
$this->currency->setValue($value['currency']);
}
public function value()
{
return [
'amount' => $this->amount->value(),
'currency' => $this->currency->value(),
];
}
}
Es necesario pasar al elemento select
los tipos de moneda válidos que el
usuario puede elegir. Supongamos que estos valores los recuperamos del catálogo de
productos.
// ...
class Catalog
{
// ...
public function validCurrencies()
{
return ['MXN', 'USD'];
}
}
Vamos a escribir ahora un filtro para validar cualquier elemento de tipo moneda.
Creamos entonces una clase MoneyFilter
que herede de Zend\InputFilter\InputFilter
.
Para el elemento amount
validaremos que se trate de un número entero.
use Zend\InputFilter\Input;
use Zend\InputFilter\InputFilter;
use Zend\Validator\Digits;
use Zend\Validator\NotEmpty;
// ...
class MoneyFilter extends InputFilter
{
// ...
protected function buildAmountInput()
{
$amount = new Input('amount');
$amount
->getValidatorChain()
->attach(new NotEmpty(['type' => NotEmpty::INTEGER]))
->attach(new Digits())
;
return $amount;
}
}
Para el elemento currency
debemos agregar un validador InArray
que verifique
que el valor proporcionado es uno de los valores permitidos por el catálogo de
productos.
use Zend\Validator\InArray;
// ...
class MoneyFilter extends InputFilter
{
// ...
public function buildCurrencyInput(array $validCurrencies)
{
$currency = new Input('currency');
$currency->setContinueIfEmpty(true);
$currency
->getValidatorChain()
->attach(new InArray([
'haystack' => $validCurrencies,
]))
;
$this->add($currency);
}
}
El objetivo del patrón de moneda es no guardar valores flotantes para evitar
problemas de redondeo, es por eso que para validar amount
hemos agregado un
validador del tipo Digits
.
Para que la validación funcione debemos multiplicar el valor introducido por el usuario por 100, si el resultado es un valor entero (contiene solo dígitos), significa que es un valor de moneda válido, ya que contiene solo dos dígitos después del punto decimal, además de que ese valor entero es el que debemos guardar en la base de datos.
// ...
class MoneyFilter extends InputFilter
{
// ...
public function setData($data)
{
$data['original_amount'] = $data['amount'];
$data['amount'] = $data['amount'] * 100;
parent::setData($data);
}
}
En el snippet anterior guardamos el valor original porque debemos mostrarlo en el formulario en el caso de que la validación falle.
Además de guardar el valor original, debemos modificar los métodos getValues
y getMessages
.
getValues
debe recuperar el valor original proporcionado por el usuario,
el cual se almacena en $this->data['original_amount']
getMessages
debe agrupar los mensajes de los dos elementos que se están
validando, ya que para el usuario final se trata de un único elemento y sus
mensajes de error deben mostrarse juntos.// ...
class MoneyFilter extends InputFilter
{
// ...
public function getValues()
{
$values = parent::getValues();
$values['amount'] = $this->data['original_amount'];
return $values;
}
public function getMessages()
{
$messages = parent::getMessages();
$moneyMessages = [];
if (isset($messages['amount'])) {
$moneyMessages = $messages['amount'];
unset($messages['amount']);
}
if (isset($messages['currency'])) {
$moneyMessages = array_merge($moneyMessages, $messages['currency']);
unset($messages['currency']);
}
$messages[$this->name] = $moneyMessages;
return $messages;
}
}
Ya que tenemos el nuevo elemento y el filtro que valida elementos de ese tipo podemos crear nuestro formulario y filtro para cambiar los precios de un producto.
Empecemos con el formulario:
use EasyForms\Form;
class ProductPricingForm extends Form
{
public function __construct()
{
$this
->add(new Money('cost_price'))
->add(new Money('sale_price'))
;
}
}
El filtro sería el siguiente:
use Zend\InputFilter\InputFilter;
class ProductPricingFilter extends InputFilter
{
public function __construct()
{
$this
->add(new MoneyFilter('cost_price'), 'cost_price')
->add(new MoneyFilter('sale_price'), 'sale_price')
;
}
}
Tanto al filtro como al formulario necesitamos pasarles los valores válidos
para currency
. Usaremos un objeto de configuración como en nuestro ejemplo
del post anterior.
class ProductPricingConfiguration
{
protected $catalog;
public function __construct(Catalog $catalog)
{
$this->catalog = $catalog;
}
public function getCurrencyChoices()
{
return array_combine(
$this->catalog->validCurrencies(),
$this->catalog->validCurrencies()
);
}
public function getCurrenciesHaystack()
{
return $this->catalog->validCurrencies();
}
}
Agregamos un método configure
tanto al filtro como al formulario.
class ProductPricingFilter extends InputFilter
{
// ...
public function configure(ProductPricingConfiguration $configuration)
{
$this
->get('cost_price')
->buildCurrencyInput($configuration->getCurrenciesHaystack())
;
$this
->get('sale_price')
->buildCurrencyInput($configuration->getCurrenciesHaystack())
;
}
}
class ProductPricingForm extends Form
{
// ...
public function configure(ProductPricingConfiguration $configuration)
{
$this
->get('cost_price')
->setCurrencyChoices($configuration->getCurrencyChoices())
;
$this
->get('sale_price')
->setCurrencyChoices($configuration->getCurrencyChoices())
;
}
}
En nuestro controlador tenemos que hacer dos cosas:
GET
recuperamos la información
del producto desde nuestra base de datos y llenamos el formulario con esos datos.POST
debemos validar la información que nos
mandó el usuario si la validación pasa, guardamos los cambios, en caso contrario
mostramos los errores en el formulario.Supongamos que tenemos una entidad producto como la siguiente:
use Money\Money;
class Product
{
protected $productId;
protected $name;
protected $description;
protected $costPrice;
protected $salePrice;
public function __construct(
$productId,
$name,
Money $costPrice,
Money$salePrice,
$description = null
)
{
$this->productId = $productId;
$this->costPrice = $costPrice;
$this->salePrice = $salePrice;
$this->name = $name;
$this->description = $description;
}
public function changePrices(Money $costPrice, Money $salePrice)
{
$this->costPrice = $costPrice;
$this->salePrice = $salePrice;
}
public function information()
{
$information = new ProductInformation();
$information->productId = $this->productId;
$information->name = $this->name;
$information->description = $this->description;
$information->costPrice = $this->costPrice;
$information->salePrice = $this->salePrice;
return $information;
}
}
Nuestro controlador sería algo similar al siguiente, el método editProductPrices
corresponde a una solicitud GET
mientras que updateProductPrices
corresponde
a POST
:
class ChangeProductPrices
{
protected $view
protected $form;
protected $validator;
protected $catalog;
public function __construct(
Twig_Environment $view,
ProductPricingForm $form,
InputFilterValidator $validator,
Catalog $catalog
)
{
$this->view = $view;
$this->form = $form;
$this->validator = $validator;
$this->catalog = $catalog;
}
public function editProductPrices($productId)
{
$product = $this->catalog->productOf($productId);
$this->form->populateFrom($product->information());
return $this->view->render('product/edit-product.html.twig', [
'form' => $this->form->buildView(),
]);
}
public function updateProductPrices(Request $request)
{
$this->form->submit($request->post());
if ($this->validator->validate($this->form)) {
$pricing = $this->form->values()
$costPrice = new Money(
(int) round($pricing['cost_price']['amount'] * 100),
new Currency($pricing['cost_price']['currency'])
);
$salePrice = new Money(
(int) round($pricing['sale_price']['amount'] * 100),
new Currency($pricing['sale_price']['currency'])
);
$product = $this->catalog->productOf($pricing['productId']);
$product->changePrices($costPrice, $salePrice);
$this->catalog->update($product);
$this->redirect('products_list');
}
return $this->view->render('product/edit-product.html.twig', [
'form' => $request->form(),
]);
}
}
Lo último que nos falta por resolver es como mostraremos nuestro elemento moneda con Twig. El objetivo es que para la plantilla sea lo más transparente posible.
{{ form_start(form) }}
{{ element_row(form.cost_price, {'label': 'Cost price', 'attr': {'id': 'cost_price'}}) }}
{{ element_row(form.sale_price, {'label': 'Sale price', 'attr': {'id': 'sale_price'}}) }}
{{ form_rest(form) }}
<button type="submit" class="btn btn-default">
<span class="glyphicon glyphicon-usd"></span> Update pricing
</button>
{{ form_end() }}
Para lograrlo debemos sobrecargar el método buildView
de nuestro elemento Money
.
Necesitamos también un MoneyView
que extienda de ElementView
que a su vez contenga
los objetos View
tanto del elemento text
como del elemento select
.
use EasyForms\View\ElementView;
class MoneyView extends ElementView
{
/** @var ElementView */
public $amount;
/** @var SelectView */
public $currency;
}
class Money extends Element
{
// ...
public function buildView(ElementView $view = null)
{
$view = new MoneyView();
$view = parent::buildView($view);
$view->amount = $this->amount->buildView();
$view->currency = $this->currency->buildView();
$view->block = 'money';
return $view;
}
}
Aprovecharemos que podemos definir bloques directamente en la plantilla
del formulario para definir un bloque especial para los elementos del
tipo Money
.
{% extends 'layouts/base.html.twig' %}
{% block title %}/ Update product prices{% endblock %}
{# Use this template to add an inline block #}
{% form_theme [_self] %}
{# Money block #}
{%- block money -%}
<div class="form-inline">
<div class="form-group">
<div class="input-group">
<div class="input-group-addon">$</div>
{# Render the money amount as a text element #}
{%- set options = options|merge({'block': 'input'}) -%}
{%- set attr = attr|merge(element.amount.attributes) -%}
{{- element(element.amount, attr, options) -}}
</div>
{# Render the money currency as a select element #}
{%- set options = options|merge({'block': 'select'}) -%}
{%- set attr = attr|merge(element.currency.attributes) -%}
{{- element(element.currency, attr, options) -}}
</div>
</div>
{%- endblock money -%}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Update product pricing</div>
<div class="panel-body">
{# The form goes here... #}
</div>
</div>
</div>
</div>
{% endblock %}
Puedes revisar un ejemplo similar en este repositorio, en el código relacionado
con la ruta /composite-element
, espero que te sea útil. Agradeceré mucho tus
comentarios, dudas, quejas, sugerencias o reclamaciones.
Slim al ser un microframework no cuenta con un concepto de módulo como tal, ya que su escencia es que puedes usar funciones anónimas como controladores y desarrollar aplicaciones de la forma más simple posible.
Aún así, si te interesa organizar el código de una aplicación mediana de forma similar a como lo harías en un framework regular, este post será de tu interés.
Te explicaré cómo puedes usar el paquete comphppuebla/slim-modules
para estructurar
tus aplicaciones Slim de forma similar a como lo harías con módulos. Para esto usaré
el ejemplo que he venido usando en post anteriores sobre una aplicación de catálago
de productos.
Supongamos que tienes una estructura de directorios similar a la siguiente para tu proyecto.
src
├── ProductCatalogModule
│ ├── Controllers
│ │ ├── SearchProductsController.php
│ │ └── ProductRequest.php
│ ├── Resources
│ │ └── templates
│ │ └── search-products.html.twig
│ ├──Forms
│ │ └── ProductForm.php
├── ProductCatalog
│ ├── Catalog.php
│ └── Product.php
Y que queremos integrar de la forma más simple posible ese código con nuestra aplicación Slim.
Primero instalamos el paquete con Composer.
$ composer require comphppuebla/slim-modules
Puedes revisar la documentación y esta aplicación que ya usa el módulo, para más detalles.
El paquete está pensado para integrar módulos, pero también puedes integrar librerías
de terceros, similar a los services providers de Silex. Supongamos que queremos
integrar Twig, podemos usar la interfaz ComPHPPuebla\Slim\ServiceProvider
de la
siguiente forma:
use ComPHPPuebla\Slim\ServiceProvider;
class TwigProvider implements ServiceProvider
{
public function configure(Slim $app, array $parameters = [])
{
$app->container->singleton('twig.loader', function() {
return new Twig_Loader_Filesystem($parameters['twig.paths']);
});
$app->container->singleton('twig.environment', function() use ($app) {
return new Twig_Environment(
$app->container->get('twig.loader'),
$parameters['twig.options']
);
});
}
}
Una vez definido, puedes registrar tu proveedor en index.php
$app = new Slim\Slim();
$twigProvider = new TwigProvider([
'twig.paths' => [
'app/templates'
'src/ProductCatalogModule/Resources/templates',
],
'twig.options' => [
'cache' => 'var/cache/twig',
'strict_variables' => true,
],
]);
$twigProvider->register($app);
$app->run();
La implementación para los servicios de un módulo es similar, solo que registraríamos controladores, repositorios, servicios de aplicación, formularios, etc. Por ejemplo:
namespace ProductCatalogModule;
use ComPHPPuebla\Slim\ServiceProvider;
use ProductCatalogModule\Controllers;
use ProductCatalogModule\Forms;
use ProductCatalog\Catalog;
class ProductCatalogServices implements ServiceProvider
{
public function configure(Slim $app, array $parameters = [])
{
$app->container->singleton(
'product_catalog.search_products_controller',
function() use ($app) {
return new SearchProductsController(
$app->container->get('twig.environment'),
new SearchProductsForm(),
$app->container->get('product_catalog.product_repository'),
);
}
);
$app->container->singleton(
'product_catalog.product_repository',
function() use ($app) {
return new new Catalog($app->container->get('dbal.connection'));
}
);
/* more services here... */
}
}
Registramos los servicios del módulo igual que hicimos con el ejemplo de Twig.
$app = new Slim\Slim();
/* More providers here... */
$productCatalog = new ProductCatalogServices();
$productCatalog->register($app);
$app->run();
Para registrar las rutas, debemos crear una clase que implemente la interfaz
ComPHPPuebla\Slim\ControllerProvider
namespace ProductCatalogModule;
use ComPHPPuebla\Slim\ControllerProvider;
use ComPHPPuebla\Slim\ControllerResolver;
use Slim\Slim;
class ProductCatalogControllers implements ControllerProvider
{
public function register(Slim $app, ControllerResolver $resolver)
{
$app->map('/catalog/search', $resolver->resolve(
$app, 'product_catalog.search_products_controller:searchProducts'
))->via('POST', 'GET');
/* More routes here... */
}
}
En el ejemplo, cada que la aplicación haga match con /catalog/search
se ejecutará
el método searchProducts
del servicio registrado con el nombre
product_catalog.search_products_controller
. El objeto ControllerResolver
usa el
patrón id_controlador:metodo
para resolver qué método se ejecutará en cada ruta.
El controlador no se crea hasta que Slim hace match con esa ruta, el resolvedor
simplemente crea una función (similar a lo que sucede cuando ejecutas
$app->container->protect
) que realiza las siguientes tareas:
id_controlador:metodo
./products/:id
, recupera el valor de $id
y lo pasa al método del controlador.Request
como penúltimo argumento y a tu aplicación Slim como
último argumento. De modo que todas las llamadas a métodos de controladores tienen
por default la misma estructura:Controller::method(/* $route_param_1, ... $route_param_n */ $request, $app)
El resolvedor puede recibir como tercer argumento una función que altere los
parámetros que se le pasan a un controlador. Supongamos que tenemos un controlador
que edita los datos de un producto. El método en el controlador sólo necesita el
ID del producto y la instancia de la aplicación de Slim para llamar al método
notFound
en caso de que no encontremos el producto asociado al ID proporcionado.
No nos hace falta en este caso el objeto Request
.
namespace ProductCatalogModule\Controllers;
/* ... */
class ProductController
{
/* ... */
public function editProduct($productId, Slim $app)
{
if (!$product = $this->catalog->productOf($productId)) {
$app->notFound();
}
// Populate your form and pass it to the view
}
/* ... */
}
Si no usamos un convertidor de argumentos, generaríamos un error porque el
argumento que pasaríamos en segundo lugar sería de tipo Request
y no de tipo
Slim
, ya que ese es el comportamiento default.
Para evitar este error registramos un convertidor que elimine el Request
de nuestro arreglo de argumentos.
# ProductCatalogModule\ProductCatalogControllers
public function register(Slim $app, ControllerResolver $resolver)
{
$app->get('/catalog/product/edit/:id', $resolver->resolve(
$app,
'product_catalog.product_controller:editProduct',
function (array $arguments) {
// $arguments[0] is the product ID
unset($arguments[1]); // Remove the request
// $arguments[2] is our Slim application
return $arguments;
}
));
/* ... */
}
Con los convertidores no solo podemos modificar los argumentos, los podemos reemplazar completamente. Supongamos que tenemos un controlador para realizar búsquedas de productos por categoría y palabras clave. Estos valores se pasan usando el query string y en la aplicación son manejados usando el siguiente objeto:
namespace ProductCatalog;
class ProductSearchCriteria
{
protected $category;
protected $keywords;
public function __construct($category = null, $keywords = null)
{
$this->category = $category;
$this->keywords = $keywords;
}
public function hasCategory()
{
return !is_null($this->category);
}
public function category()
{
return $this->category;
}
public function hasKeywords()
{
return !is_null($this->keywords);
}
public function keywords()
{
return $this->keyword;
}
}
Sin un convertidor de argumentos, nuestro controlador tendría código como este:
namespace ProductCatalogModule\Controllers;
/* .. */
class SearchController
{
/* ... */
public function searchProducts(Request $request)
{
$results = $this->catalog->productsMatching(new ProductSearchCriteria(
$request->get('category'), $request->get('keywords')
));
// Pass your results to the view
}
}
Con un convertidor podríamos pasar directamente el objeto ProductSearchCriteria
al método del controlador en lugar de pasar el objeto Request
# ProductCatalogModule\ProductCatalogControllers
public function register(Slim $app, ControllerResolver $resolver)
{
$app->get('/catalog/product/search', $resolver->resolve(
$app,
'product_catalog.product_search_controller:searchProducts',
function (array $arguments) {
// $arguments[0] is the request, our route does not have parameters
return [new ProductSearchCriteria(
$arguments[0]->get('category'), $arguments[0]->get('keywords')
)];
}
));
/* ... */
}
Con este simple cambio, podemos modificar la firma del controlador.
namespace ProductCatalogModule\Controllers;
/* .. */
class SearchController
{
/* ... */
public function searchProducts(ProductSearchCriteria $criteria)
{
$results = $this->catalog->productsMatching($criteria);
// Pass your results to the view
}
}
En los ejemplos anteriores hemos registrado nuestros servicios por separado,
sin embargo, podemos incluir todas nuestras definiciones en una sola clase si
extendemos de ComPHPPuebla\Slim\Services
.
Podemos registrar todos nuestros proveedores en el método init
usando el
método add
.
namespace Application;
use ComPHPPuebla\Slim\Services;
use ProductCatalogModule\ProductCatalogServices;
class ApplicationServices extends Services
{
/**
* Add the providers for your modules here
*/
protected function init()
{
$this
->add(new ProductCatalogServices())
// Register more modules here...
->add(new TwigProvider())
// Register more providers here...
;
}
}
También podemos agrupar el registro de las rutas en una sola clase
si extendemos de ComPHPPuebla\Slim\Controllers
, también agregamos nuestros
controladores en el método init
el cual se llama automáticamente al
registrar nuestras rutas.
namespace Application;
use ComPHPPuebla\Slim\Controllers;
use ProductCatalogModule\ProductCatalogControllers;
class ApplicationControllers extends Controllers
{
protected function init()
{
$this
->add(new ProductCatalogControllers())
// Register more controllers modules here...
;
}
}
Una vez agrupadas las definiciones de todos tus servicios y todas tus rutas,
la configuración en tu archivo index.php
se reduce a algo similar a las
siguientes líneas.
$app = new Slim\Slim();
$services = new Application\ApplicationServices();
$services->configure($app);
$controllers = new Application\ApplicationControllers();
$controllers->register($app);
$app->run();
Agradeceré mucho tus comentarios, dudas, quejas, sugerencias o reclamaciones.
]]>En PHP existen varios paquetes que nos permiten trabajar con formularios, entre los más populares están los componentes de ZF2 y Symfony2. Sin embargo, creo que tienen sus desventajas si queremos usarlos fuera de su respectivo framework. Ambos paquetes requieren de varias dependencias que podrías no necesitar en tu proyecto. Estas son las dependencias que instalas al requerir cualquiera de los dos paquetes:
Paquete | Dependencias |
---|---|
zendframework/zend-form |
|
symfony/form |
|
Me gusta el enfoque que usa Symfony2 ya que permite agregar funcionalidad, a través de extensiones que permiten la integración con otros paquetes, por ejemplo: validación, HTTP Foundation, Twig, etc. Aunque como puedes observar, tienes que instalar componentes que tal vez no uses como el manejador de eventos o el componente de internacionalización.
En este post explicaré a través de ejemplos como creo que podríamos desacoplar de una mejor forma el manejo de formularios y evitar instalar paquetes que tal vez no necesitemos, además de simplificar las tareas rutinarias con formularios, separando claramente las diferentes responsabilidades relacionadas con formularios, evitando así que el formulario sepa hacer todo. Para esto revisaré la siguiente funcionalidad:
Para los ejemplos usare el paquete comphppuebla/easy-forms que puedes instalar con Composer
$ composer require comphppuebla/easy-forms:~1.0@dev
Si encuentras la librería interesante, por favor revisa la documentación
Cuando procesamos un formulario solo necesitamos saber el nombre de los elementos en el formulario,
ya que esos nombres se mapean con las llaves en las variables superglobales $_GET
, $_POST
y
$_FILES
. Las etiquetas HTML, los atributos HTML, los validadores, y mensajes de error no son
responsabilidad de los elementos o del formulario en sí, todas esas tareas corresponden a otros
componentes (validación y plantillas respectivamente).
La forma más simple de crear un formulario con este paquete es extendiendo de la clase
EasyForms\Form
. Por ejemplo, si queremos un formulario para login tendríamos la siguiente clase.
use EasyForms\Elements\Text;
use EasyForms\Elements\Password;
use EasyForms\Form;
class LoginForm extends Form
{
public function __construct()
{
$this
->add(new Text('username'))
->add(new Password('password'))
;
}
}
Si ya tienes un componente de validación puedes pasar los valores a LoginForm
para mostrarlos
en una plantilla. Nota que para procesar el formulario no necesitamos saber cómo se validan
o filtran sus datos, ni como se mostrarán al usuario.
$loginForm = new LoginForm();
$errors = $validator->validate($_POST); // whatever component you use
$loginForm->submit($_POST);
$loginForm->setErrorMessages($errors);
// Render the form however you want...
Ya vimos que el formulario no necesita saber de un componente de validación, sin embargo el paquete
proporciona una interfaz EasyForms\Validation\FormValidator
que puedes usar para integrar cualquier
componente de validación. El paquete ya cuenta con una integración con zend-inputfilter.
Supongamos que ya tenemos el siguiente filtro:
use Zend\Filter\StringTrim;
use Zend\InputFilter\Input;
use Zend\InputFilter\InputFilter;
use Zend\Validator\NotEmpty;
use Zend\Validator\StringLength;
class LoginFilter extends InputFilter
{
public function __construct()
{
$this
->add($this->buildUsernameInput())
->add($this->buildPasswordInput())
;
}
protected function buildUsernameInput()
{
$username = new Input('username');
$username
->getValidatorChain()
->attach(new NotEmpty())
->attach(new StringLength([
'min' => 3,
]))
;
$username
->getFilterChain()
->attach(new StringTrim())
;
return $username;
}
protected function buildPasswordInput()
{
$password = new Input('password');
$password
->getValidatorChain()
->attach(new NotEmpty())
->attach(new StringLength([
'min' => 8,
]))
;
return $password;
}
}
Una primera opción es seguir usándolo sin integrarlo al formulario:
$filter = new LoginFilter();
$filter->setData($_POST);
if (!$filter->isValid()) {
$form->setErrorMessages($filter->getMessages());
}
$form->submit($filter->getValues());
// Render the form however you want...
La segunda opción es usar el validador que ya viene incluido en el paquete
use EasyForms\Bridges\Zend\InputFilter\InputFilterValidator;
$validator = new InputFilterValidator(new LoginFilter());
$validator->validate($form = new LoginForm());
// Render the form however you want... Error messages will be set, if any
Como ya explicamos, el formulario puede usar cualquier mecanismo de validación, lo mismo sucede para la capa de presentación el formulario no necesita saber que apariencia tendrá.
La forma más simple de mostrar un formulario en una plantilla es simplemente llamando al método
buildView
del formulario y pasar el resultado a cualquier motor de plantillas que usemos,
supongamos incluso que no usamos uno (aunque deberíamos):
$view = $form->buildView()
// inside your template
echo "<label for=\"{$view->username->attributes['name']}\">Username</label>";
$htmlAttributes = '';
foreach ($view->username->attributes as $attribute => $value) {
$htmlAttributes .= "{$attribute}=\"{$value}\" ";
}
echo '<input ' . trim($htmlAttributes) . '>';
El paquete cuenta con una integración con Twig, inspirada en la forma en la que se muestra los formularios
de Symfony2. La integración consiste de una extensión con 3 funciones principales form_start
,
form_end
y element_row
. La explicación de las primeras dos funciones es un tanto obvia.
La funcion element_row
muestra al elemento en tres secciones, una etiqueta, el elemento HTML
y los mensajes de error.
La extensión se registra de la siguiente forma:
use EasyForms\Bridges\Twig\BlockOptions;
use EasyForms\Bridges\Twig\FormExtension;
use EasyForms\Bridges\Twig\FormRenderer;
use EasyForms\Bridges\Twig\FormTheme;
$environment = new Twig_Environment(new Twig_Loader([
'vendor/comphppuebla/easy-forms/src/EasyForms/Bridges/Twig', // extension templates
'path/to/your/templates',
]));
$theme = new FormTheme($environment, "layouts/bootstrap3.html.twig"); // form's theme
$renderer = new FormRenderer($theme, new BlockOptions());
$environment->addExtension(new FormExtension($renderer));
Como podemos observar en el código, la extensión utiliza temas para dar formato a los formularios.
Un tema es un grupo de plantillas que define o sobrescribe los bloques que muestran cada uno de los
elementos del formulario. El paquete cuenta con dos temas por defecto, uno es el tema default
que simplemente agrupa un elemento, su etiqueta y mensajes de error dentro de un div
. El otro
tema da formato a un formulario con Bootstrap 3.
Supongamos que pasamos desde el controlador nuestro formulario a una plantilla de Twig.
$environment->render('login.html.twig', [
'login' => $loginForm->buildView(),
]);
El código que usaríamos en nuestra plantilla de Twig para mostrar el formulario sería:
{{ form_start(login) }}
{{ element_row(login.username, {'label': 'Username', 'attr': {'id': 'username'}}) }}
{{ element_row(login.password, {'label': 'Password', 'attr': {'id': 'password'}}) }}
<button type="submit" class="btn btn-default">
<span class="glyphicon glyphicon-home"></span> Login
</button>
{{ form_end() }}
Como podemos observar, element_row
recibe dos argumentos, el primero es el elemento del formulario
y el segundo es un arreglo asociativo, donde podemos definir la etiqueta del elemento label
,
los atributos HTML de la etiqueta label_attr
, los atributos HTML del elemento attr
y options
que nos sirve para sobrescribir los valores de los bloques que se usan en la plantilla para mostrar
el elemento. Los bloques que podemos sobrescribir en options
son block
que sobrescribe el HTML
por default del elemento, y rowBlock
que sobrescribe la forma en que se muestran la etiqueta, el
elemento y sus mensajes de error.
Podemos agregar plantillas a nuestro tema o usar la misma plantilla que despliega nuestro formulario para definir y sobrescribir bloques. Por ejemplo, supongamos que tenemos un formulario para agregar productos a un catálogo en una aplicación de e-commerce y queremos dar formato de moneda al elemento donde capturamos el precio unitario del elemento.
{# Use this template as part of the theme #}
{% form_theme [_self] %}
{# Custom block #}
{%- block money -%}
<div class="input-group"><div class="input-group-addon">$</div>
{%- set options = options|merge({'block': 'input'}) -%}
{{- element(element, attr, options) -}}
<div class="input-group-addon">.00</div></div>
{%- endblock money -%}
{{ form_start(product) }}
{{ element_row(product.name, {'label': 'Name'}) }}
{{ element_row(product.description, {'label': 'Description'}) }}
{# Override the element's default rendering block #}
{{ element_row(product.unitPrice, {'label': 'Unit price', 'options': {'block': 'money'}}) }}
<button type="submit" class="btn btn-default">
<span class="glyphicon glyphicon-th-list"></span> Add to catalog
</button>
{{ form_end() }}
En el ejemplo definimos un bloque personalizado llamado money
y sobrescribimos el bloque por defecto
que usa nuestro elemento unitPrice
para que lo utilice.
De este modo desacoplamos el procesamiento y validación del formulario de la forma en que se presenta
al usuario, si necesitamos por ejemplo usar Foundation en lugar de Bootstrap, solo necesitamos
crear un tema que herede del tema default y agregar las clases que usa Foundation. O si necesitamos
usar Blade en lugar de Twig podemos crear una extensión que use sections
en lugar de blocks
que funcione como la extensión de Twig.
Hasta ahora hemos visto como procesar, validar y mostrar los datos de un formulario, dejando cada responsabilidad a su respectivo componente. Hay funcionalidad en formularios que tal vez no necesitemos siempre como pueden ser los captchas y los tokens para prevenir CSRF.
Para manejar captchas el paquete cuenta con una integración con zend-captcha y actualmente podemos usar captchas de imagen y reCaptcha (la versión anterior al No Captcha captcha). Supongamos que tenemos un formulario para comentarios:
use EasyForms\Elements\Captcha;
use EasyForms\Elements\Captcha\CaptchaAdapter;
use EasyForms\Elements\TextArea;
use EasyForms\Form;
class CommentForm extends Form
{
public function __construct(CaptchaAdapter $adapter)
{
$this
->add(new TextArea('message'))
->add(new Captcha('captcha', $adapter))
;
}
}
Al usar un adaptador, el formulario no necesita saber que tipo de captcha va a usar si de imagen o ReCaptcha o algún otro. Si queremos usar reCaptcha lo único que debemos hacer es pasarle como argumento el adaptador indicado, por ejemplo:
use EasyForms\Bridges\Zend\Captcha\ReCaptchaAdapter;
use Zend\Captcha\ReCaptcha;
use Zend\Http\Client;
use ZendService\ReCaptcha\ReCaptcha as ReCaptchaService;
$reCaptcha = new ReCaptchaAdapter($captcha = new ReCaptcha([
'service' => new ReCaptchaService(
'your_public_key_xxx',
'your_private_key_xxx',
$params = null,
$options = null,
$ip = null,
new Client($uri = null, ['adapter' => new Client\Adapter\Curl()])
)
]));
$form = new CommentForm($reCaptcha);
Para mostrar el formulario en una plantilla Twig, necesitamos agregar la plantilla para captchas al tema, como la funcionalidad de captchas es opcional, no está incluida en los temas por default. Así, nuestra plantilla con el captcha quedaría como:
{% form_theme ['layouts/captcha-bootstrap3.html.twig'] %}
{{ form_start(comment) }}
{{ element_row(comment.message, {'label': 'Share your opinion'}) }}
{{ element_row(comment.captcha, {'label': 'Type the words in the image below'}) }}
<button type="submit" class="btn btn-default">
<span class="glyphicon glyphicon-comment"></span> Comment
</button>
{{ form_end() }}
Para validar el captcha podemos usar el siguiente filtro:
use Zend\Captcha\ReCaptcha;
use Zend\Filter\StringTrim;
use Zend\Filter\StripTags;
use Zend\InputFilter\Input;
use Zend\InputFilter\InputFilter;
use Zend\Validator\NotEmpty;
use Zend\Validator\StringLength;
class CommentFilter extends InputFilter
{
public function __construct(ReCaptcha $validator)
{
$this
->add($this->buildMessageInput())
->add($this->buildCaptchaInput($validator))
;
}
protected function buildMessageInput()
{
$message = new Input('message');
$message
->getFilterChain()
->attach(new StringTrim())
->attach(new StripTags())
;
$message
->getValidatorChain()
->attach(new NotEmpty())
->attach(new StringLength([
'max' => 2000,
]))
;
return $message;
}
public function buildCaptchaInput(ReCaptcha $validator)
{
$reCaptcha = new Input('captcha');
$reCaptcha->setContinueIfEmpty(true);
$reCaptcha
->getValidatorChain()
->attach($validator)
;
return $reCaptcha;
}
}
En nuestro controlador tendríamos algo como esto:
$validator = new InputFilterValidator(new CommentFilter($captcha));
$validator->validate($form = new CommentForm($reCaptcha));
// Render the form...
Este paquete cuenta también con una integración con symfony/security-csrf. Podemos usar como
ejemplo nuestro formulario de login y agregarle un token CSRF, usando un objeto de la clase Csrf
Este elemento necesita dos argumentos, un identificador para el token y un proveedor de tokens.
use EasyForms\Elements\Csrf\TokenProvider;
use EasyForms\Elements\Text;
use EasyForms\Elements\Password;
use EasyForms\Elements\Csrf;
use EasyForms\Form;
class LoginForm extends Form
{
public function __construct(TokenProvider $csrfTokenProvider)
{
$this
->add(new Text('username'))
->add(new Password('password'))
->add(new Csrf('csrf_token', '_login_csrf_token', $csrfTokenProvider))
;
}
}
El proveedor es una interfaz así que podemos usar un componente distinto al de Symfony2, si queremos, sin afectar nuestro formulario. Podemos entonces crear un proveedor para nuestro formulario de la siguiente forma:
use EasyForms\Bridges\SymfonyCsrf\CsrfTokenProvider;
use Symfony\Component\Security\Csrf\CsrfTokenManager;
use Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator;
use Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage;
$provider = new CsrfTokenProvider(
new CsrfTokenManager(new UriSafeTokenGenerator(), new NativeSessionTokenStorage())
);
$form = new LoginForm($provider);
// Process the form...
// Pass it to template...
Para validar el token podemos agregar un validador a nuestro filtro anterior de la siguiente forma:
use EasyForms\Bridges\Zend\InputFilter\Validator\CsrfValidator;
use EasyForms\Elements\Csrf\TokenProvider;
use Zend\InputFilter\Input;
use Zend\InputFilter\InputFilter;
class LoginFilter extends InputFilter
{
public function __construct(TokenProvider $tokenProvider)
{
$this
/* ... */
->add($this->buildCsrfInput($tokenProvider))
;
}
/* ... */
protected function buildCsrfInput(TokenProvider $tokenProvider)
{
$csrf = new Input('csrf_token');
$csrf->setContinueIfEmpty(true);
$csrf
->getValidatorChain()
->attach(new CsrfValidator([
'tokenProvider' => $tokenProvider,
'tokenId' => '_login_csrf_token',
'updateToken' => true,
]))
;
return $csrf;
}
}
Podemos entonces pasar nuestro proveedor al filtro y validar como de costumbre.
use EasyForms\Bridges\Zend\InputFilter\InputFilterValidator;
$validator = new InputFilterValidator(new LoginFilter($provider));
$form = new LoginForm($provider);
$form->submit($_POST);
$validator->validate($form);
// Render the form...
Para mostrar este elemento solo debemos agregarlo a la plantilla, no hay necesidad de agregar
plantillas al tema, ya que este elemento es un hidden
común en nuestro formulario.
{{ form_start(login) }}
{# ... #}
{{ element_row(login.csrf_token) }}
{# ... #}
{{ form_end() }}
Al trabajar con formularios es común que llenemos sus valores con información de nuestra base
de datos, o que agreguemos opciones a los select
con los datos de una tabla, y que eso se
tenga que ver reflejado en los validadores de ese elemento. Creo que estas tareas no son
responsabilidad del formulario, en su lugar podemos generar objetos que configuren el
formulario. Regresemos al ejemplo de agregar productos al catálogo de una aplicación de
e-commerce.
use EasyForms\Elements\Text;
use EasyForms\Elements\TextArea;
use EasyForms\Form;
class ProductForm extends Form
{
public function __construct()
{
$this
->add(new Text('name'))
->add(new Text('unitPrice'))
->add(new Select('category'))
;
}
}
Queremos que al cargar el formulario las categorías de los productos se agreguen a las opciones
del select
. Supongamos que nuestra clase Catalog
es la responsable de manejar los datos de los
productos en la base de datos. El método getCategoryOptions
consulta la base de datos a través de
Catalog
y genera un array asociativo con los IDs y los nombres de las categorías.
class ProductFormConfiguration
{
protected $catalog;
protected $categoryOptions;
public function __construct(Catalog $catalog)
{
$this->catalog = $catalog;
}
public function getCategoryOptions()
{
$this->categoryOptions = [];
array_map(function (CategoryInformation $category) use (&$options) {
$this->categoryOptions[$category->categoryId] = $category->name;
}, $this->catalog->allCategories());
return $this->categoryOptions;
}
}
Podemos entonces agregar un método al formulario que reciba como argumento nuestro objeto de configuración y agregue las categorías al elemento correspondiente. Por ejemplo:
class ProductForm extends Form
{
/* .. */
public function configure(ProductFormConfiguration $configuration)
{
/** @var Select $category */
$category = $this->get('category');
$category->setChoices($configuration->getCategoryOptions());
}
}
En nuestro controlador tendríamos el siguiente código:
$form = new ProductForm();
$form->configure(new ProductFormConfiguration(new Catalog());
// work with the form
Podemos actualizar el validador con una estrategía similar, supongamos que nuestro filtro verifica que la categoría, es alguna de las que tenemos en nuestro catálogo y tenemos el siguiente filtro:
use Zend\InputFilter\Input;
use Zend\InputFilter\InputFilter;
use Zend\Validator\InArray;
use Zend\Validator\Int;
use Zend\Validator\NotEmpty;
class ProductFilter extends InputFilter
{
public function __construct()
{
$this
/* ... */
->add($this->buildCategoryInput())
;
}
/* ... */
protected function buildCategoryInput()
{
$category = new Input('category');
$category
->getValidatorChain()
->attach(new NotEmpty())
;
$category
->getFilterChain()
->attach(new Int())
;
return $category;
}
public function configure(ProductFormConfiguration $configuration)
{
$category = $this->get('category');
$category
->getValidatorChain()
->attach(new InArray([
'haystack' => $configuration->getValidCategories(),
]))
;
}
}
El método getValidCategories
funciona de forma similar, la diferencia es que el método
devuelve únicamente los IDs de las categorías, que es lo que el validador necesita.
class ProductFormConfiguration
{
/* ... */
public function getValidCategories()
{
if (!$this->categoryOptions) {
$this->categoryOptions();
}
return array_keys($this->categoryOptions);
}
}
El filtro se configuraría de forma similar en nuestro controlador
use EasyForms\Bridges\Zend\InputFilter\InputFilterValidator;
$configuration = new ProductFormConfiguration(new Catalog());
$form = new ProductForm();
$form->configure($configuration);
$filter = new ProductFilter();
$filter->configure($configuration);
$validator = new InputFilterValidator($filter);
$form->submit($_POST);
$validator->validate($form)
// do more work with the form...
El último caso que revisaremos es cuando editamos un registro de la base de datos usando un formulario. Si seguimos con nuestro ejemplo, la forma más simple es agregar un método al formulario que reciba un producto y asigne los valores de las propiedades del producto a los elementos del formulario.
class ProductForm extends Form
{
/* ... */
public function addProductId()
{
$this->add(new Hidden('productId'));
}
public function populateFrom(ProductInformation $product)
{
$this->populate([
'productId' => $product->productId,
'unitPrice' => $product->unitPrice,
'name' => $product->name,
'category' => $product->categoryId,
]);
}
}
Donde el objeto ProductInformation
es un DTO con los datos de un producto que recuperamos
de nuestro catálogo. Nuestro controlador sería algo similar al siguiente:
$form->addProductId(); // Add the ID to be able to update the record
$form->populateFrom($product = $catalog->productOf($productId));
// Values of product are now in the form's elements values
Gracias por haber leido hasta aquí, si quieres ver más ejemplos revisa este repositorio. En el siguiente post explicaré como la separación de responsabilidades facilita realizar tareas como traducción y la integración de elementos que usan JavaScript (barras de progreso o vistas de árbol, por ejemplo).
Agradeceré mucho tus comentarios, dudas, quejas, sugerencias o reclamaciones.
]]>En este post desarrollaremos las siguientes tareas:
Codeception es un framework de pruebas para PHP cuyo objetivo es crear tests legibles que describan acciones desde la perspectiva del usuario.
Instalaremos Codeception usando Composer. Codeception proporciona una interfaz de línea de comando que por default se
instala en vendor/bin/codecept
. Nosotros modificaremos nuestro archivo composer.json
para que quede instalado en
bin/codecept
.
"config": {
"bin-dir": "bin/"
}
Una vez configurado agregamos el paquete de Codeception como una dependencia de desarrollo.
$ composer require --dev codeception/codeception
Después de instalar podemos inicializar nuestro ambiente de pruebas con el siguiente comando:
$ php bin/codecept bootstrap
Como Codeception nos permite hacer pruebas de aceptación, funcionales y unitarias, este comando crea la siguiente estructura de directorios. Cada tipo de prueba esta separada en suites. En lo personal uso Codeception únicamente para pruebas de aceptación. Así que lo siguiente que hago, por lo general, es eliminar todos los archivos que no están relacionados con ese tipo de pruebas.
tests/
├── acceptance
│ ├── AcceptanceTester.php
│ └── _bootstrap.php
├── acceptance.suite.yml
├── _bootstrap.php
├── _data
│ └── dump.sql
├── functional
│ ├── _bootstrap.php
│ └── FunctionalTester.php
├── functional.suite.yml
├── _output
├── _support
│ ├── AcceptanceHelper.php
│ ├── FunctionalHelper.php
│ └── UnitHelper.php
├── unit
│ ├── _bootstrap.php
│ └── UnitTester.php
└── unit.suite.yml
Ya que las pruebas se representan como acciones realizadas por un usuario, un actor es un objeto que representa a
una persona realizando pruebas a nuestra aplicación. En nuestro caso trabajaremos con el actor AcceptanceTester
. Las
clases que representan a los actores se generan a partir de la configuración de cada suite.
Si modificamos la configuración de alguna suite podemos actualizar la definición de nuestros actores con el comando:
$ php bin/codecept build
Por defecto las pruebas en codeception se escriben como escenarios narrativos. Para crear un escenario debemos crear un
archivo con el sufijo Cept
. Podemos crear una prueba usando el comando:
$ php bin/codecept generate:cept acceptance ShoppingCart
La prueba más simple consiste en pasar a nuestro actor un escenario.
<?php
# tests/acceptance/ShoppingCartCept.php
$I = new AcceptanceTester($scenario);
?>
Existe otro tipo de formato para las pruebas llamado Cest
, el cual es mi preferido. Un test del tipo Cest
agrupa
nuestras pruebas en clases. Podemos crear un Cest
con el comando:
$ php bin/codecept generate:cest acceptance ShoppingCart
Este comando genera una clase como la siguiente:
<?php
# tests/acceptance/ShoppingCartCest.php
class ShoppingCartCest
{
public function _before(AcceptanceTester $I)
{
}
public function _after(AcceptanceTester $I)
{
}
// tests
public function tryToTest(AcceptanceTester $I)
{
}
}
Cada método públic en un Cest (excepto por los que inician con _
) se ejecutarán como una prueba y recibirán como
argumento un objeto actor y un escenario como segundo argumento.
Los métoodos _before
y _after
son los equivalentes del setUp
y tearDown
en PHPUnit y se ejecutan antes y después
de cada test respectivamente.
Para nuestras pruebas usaremos el navegador headless de PhantomJS en el modo ghostdriver, para lo cual debemos modificar el archivo de configuración.
# tests/acceptance.suite.yml
class_name: AcceptanceTester
modules:
enabled:
- WebDriver
- AcceptanceHelper
config:
WebDriver:
url: 'http://shoppingcart.dev/'
browser: phantomjs
Por último, usaremos Grunt para ejecutar las pruebas. Debes instalar de forma global Grunt con npm.
$ npm install -g grunt-cli
Una vez que tenemos la instalación global de Grunt es necesario agregarlo también a nuestro archivo packages.json
junto con PhantomJS y un par de tareas que nos servirán para ejecutar nuestros tests.
{
"devDependencies": {
"grunt": "~0.4",
"grunt-run": "~0.3",
"grunt-exec": "~0.4",
"phantomjs": "~1.9"
}
}
En nuestro archivo Gruntfile.js
registramos una nueva tarea que inicie PhantomJS, ejecute los tests y por último
detenga PhantomJS.
module.exports = function(grunt) {
var phantomjs = require('phantomjs');
var phantombin = phantomjs.path;
grunt.initConfig({
exec: {
codecept: {
stdout: true,
command: [
'php bin/codecept clean',
'php bin/codecept run web --steps'
].join('&&')
}
},
run: {
phantomjs: {
options: {
wait: false,
quiet: true,
ready: /running on port/
},
cmd: phantombin,
args: [
'--webdriver=4444'
],
}
}
});
grunt.loadNpmTasks('grunt-exec');
grunt.loadNpmTasks('grunt-run');
grunt.registerTask('default', []);
grunt.registerTask('test', ['run:phantomjs', 'exec:codecept', 'stop:phantomjs']);
};
Una vez configurado todo podemos correr nuestro Cest
con el comando:
$ grunt test
Como nuestro test está vacío debemos ver un resultado similar al siguiente:
Acceptance Tests (1) -------------------------------------------------------------------------
Trying to try to test (ShoppingCartCest::tryToTest)
Scenario:
PASSED
Para nuestros test de aceptación necesitamos crear unos fixtures de datos con Alice. Alice nos permite generar fixtures con datos ficticios para nuestras pruebas, usando archivos YAML. Podemos instalar Alice con Composer.
$ composer require --dev nelmio/alice
Alice cuenta con una integración con Doctrine ORM, sin embargo, para mantener nuestro ejemplo simple, nuestro proyecto solo usa PDO. Nuestra clase producto es la siguiente:
class Product
{
protected $productId;
protected $name;
protected $unitPrice;
public function __construct($productId, $name, $unitPrice)
{
$this->prductId = $productId;
$this->name = $name;
$this->unitPrice = $unitPrice;
}
public function productId()
{
return $this->productId;
}
public function name()
{
return $this->name;
}
public function unitPrice()
{
return $this->unitPrice;
}
}
Nuestra clase ProductCatalog
es la encargada de persistir nuestra información. Para mantener el ejemplo simple, usamos
SQLite.
class ProductCatalog
{
protected $connection;
public function __construct(PDO $connection)
{
$this->connection = $connection;
}
public function add(Product $product)
{
$sql = 'INSERT INTO products(product_id, name, unit_price) VALUES (?, ?, ?)';
$statement = $this->connection->prepare($sql);
$statement->execute([
$product->productId(),
$product->name(),
$product->unitPrice(),
]);
}
}
Para mantener el código de nuestras rutas de Slim limpio, registramos la conexión a la base de datos y nuestro catálogo como servicios en el componente de inyección de dependencias de Slim.
# app/resources.php
$app->container->singleton('connection', function() {
$connection = new PDO('sqlite:var/store.sqlite');
$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $connection;
});
$app->container->singleton('catalog', function() use ($app) {
return new ProductCatalog($app->connection);
});
Para cargar los datos de nuestros fixtures crearemos una clase que utilice la misma conexión que definimos en nuestra aplicación de Slim.
# tests/support/FixturesLoader.php
use Slim\Slim;
class FixturesLoader
{
protected $connection;
public function __construct()
{
$app = new Slim();
require __DIR__ . '/../../app/resources.php';
$this->connection = $app->connection;
}
public function connection()
{
return $this->connection;
}
}
La clase Loader\Yaml
de Alice creará nuestras entidades usando Reflection, así que para nuestro ejemplo, la
variable $entities
será un arreglo de objetos Product
. El segundo argumento de el método loadFixture
es un objeto
que nos permitirá guardar los datos de las entidades en la base de datos, en nuestro caso, un objeto de la clase
ProductCatalog
.
use Nelmio\Alice\Loader\Yaml;
class FixturesLoader
{
// ...
public function loadFixture($fixture, $persister)
{
$loader = new Yaml();
$entities = $loader->load($fixture);
array_map(function($entity) use ($persister) {
$persister->add($entity);
}, $entities);
}
}
Cómo nuestros tests se ejecutarán muchas veces, necesitamos de un método que nos permita eliminar los datos que se generaron en pruebas anteriores.
class FixturesLoader
{
// ...
public function purge($table)
{
$statement = $this->connection->prepare(sprintf('DELETE FROM %s', $table));
$statement->execute();
}
}
Ahora que tenemos nuestra clase para cargar fixtures, la podemos usar en el método _before
de nuestro Cest
# tests/acceptance/ShoppingCartCest.php
class ShoppingCartCest
{
/** @type FixturesLoader */
protected $loader;
public function _before()
{
$this->loader = new FixturesLoader();
$this->loader->purge('products');
$this->loader->loadFixture(
__DIR__ . '/../_data/fixtures/products.yml',
new ProductCatalog($this->loader->connection())
);
}
// ...
}
Por último debemos generar el archivo .yml
con los datos de prueba. Lo primero que necesita nuestro archivo es el nombre
de la clase, cada entrada después del nombre de la clase representa un objeto de esa clase. Debemos colocarles un nombre
para poder identificarlos después, en caso que se usen como referencias en otros objetos.
# tests/_data/fixtures/products.yml
Store\Product:
product0:
__construct: false # Do not use the constructor
productId: 1
name: Tetris
unitPrice: 100.20
product1:
__construct: false
productId: 2
name: Minecraft
unitPrice: 200.80
Aunque resulta relativamente simple generar datos manualmente, una mejor opción es la generación automática. Para este
fin, Alice usa los proveedores de datos de Faker. Para usar un proveedor de Faker solo es necesario usar el nombre
del método y sus argumentos, cuando sea necesario, después de la priopiedad del objeto en nuestro archivo YAML. En el
ejemplo estamos usando también rangos product{2..12}
para generar de forma automática los identificadores de los
objetos, en este caso serán desde product2
hasta product12
.
Store\Product:
product{2..12}:
__construct: false
productId (unique): <numberBetween(1, 20)>
name: <sentence(2)>
unitPrice: <randomFloat(2, 5, 100)>
Si ejecutamos nuevamente nuestro test y hacemos una consulta a nuestra tabla de productos, veremos una salida similar a la siguiente:
Podemos generar nuestro propio proveedor de datos aleatorios para hacer que todos nuestros productos sean videojuegos. Un proveedor no requiere de nada particular, los método publicos del proveedor que registremos con Alice estarán disponibles desde nuestro archivo de fixtures.
# tests/support/ProductsProvider.php
class ProductsProvider
{
protected $products = [
'Super Mario Bros',
'Grand Theft Auto',
'Call of Duty',
'Mario Kart',
'Pokémon Diamond and Pearl',
'Sonic the Hedgehog',
'Diablo III',
'Battlefield 3',
'Mortal Kombat II',
'Street Fighter II: Special Champion Edition',
];
public function product()
{
return $this->products[array_rand($this->products)];
}
}
Para que funcione debemos pasar el proveedor a nuestro loader.
# ShoppingCartCest::_before
$this->loader->loadFixture(
__DIR__ . '/../_data/fixtures/products.yml',
new ProductCatalog($this->loader->connection()),
[new ProductsProvider()] // Our provider
);
# FixturesLoader::loadFixtures
public function loadFixture($fixture, $persister, $providers = [])
{
$loader = new Yaml('en_US', $providers);
// ...
}
Y utilizarlo en nuestro archivo de fixtures.
product{2..12}:
__construct: false
productId (unique): <numberBetween(3, 20)>
name: <product()>
unitPrice: <randomFloat(2, 5, 100)>
Los datos generados ahora, serían similares a los siguientes:
El objetivo de las pruebas es que describan las acciones como si fueran realizadas por un usuario de la aplicación. Para ese fin codeception nos proporciona algunos métodos para describir el objetivo de cada prueba similar al formato Connextra.
$I->am('videogames buyer');
$I->wantTo('buy my favorite videogames');
$I->lookForwardTo('add videogames to my shopping cart');
Estos pasos mostraran una salida más descriptiva que nos permite saber cuál es el propósito de nuestro test.
Trying to buy my favorite videogames (ShoppingCartCest::toAddProductsToMyShoppingCart)
Scenario:
* As a videogames buyer
* So that I add videogames to my shopping cart
A fin de evitar colocar código CSS o XPath directamente en nuestros tests, Codeception cuenta con una implementación del patrón de diseño PageObject que representa una página Web como una clase y los elementos del DOM como sus propiedades.
$ php bin/codecept generate:pageobject ShoppingCartPage
En nuestra página de carrito los elementos DOM que nos interesan son: el select
con los productos, el text
con la
cantidad de productos a comprar, el botón para agregar el producto. También es importante verificar que se actualicen
los valores del precio total del producto seleccionado (precio unitario multpilicado por la cantidad) y la celda con el
total a pagar por los productos seleccionados.
class ShoppingCartPage
{
public static $URL = '/order';
public static $product = 'Product';
public static $quantity = 'Quantity';
public static $addToCart = 'Add to cart';
public static $firstItemPrice = '//tbody//tr[1]//td[last()]';
public static $secondItemPrice = '//tbody//tr[2]//td[last()]';
public static $total = '#cart-total';
}
Nuestra prueba agregaría 5 copias de Tetris que nos da un total de $501.50 ($100.21 cada uno) y dos copias de Minecraft $401.66 ($200.83 cada uno). El total que debe tener nuestro carro de compra es de $902.71.
public function tryToAddProductsToMyShoppingCart(AcceptanceTester $I)
{
$I->am('videogames buyer');
$I->wantTo('buy my favorite videogames');
$I->lookForwardTo('add videogames to my shopping cart');
$I->amOnPage(ShoppingCartPage::$URL);
$I->selectOption(ShoppingCartPage::$product, 'Tetris');
$I->fillField(ShoppingCartPage::$quantity, 5);
$I->click(ShoppingCartPage::$addToCart);
$I->see(501.05, ShoppingCartPage::$firstItemPrice);
$I->selectOption(ShoppingCartPage::$product, 'Minecraft');
$I->fillField(ShoppingCartPage::$quantity, 2);
$I->click(ShoppingCartPage::$addToCart);
$I->see(401.66, ShoppingCartPage::$secondItemPrice);
$I->see(902.71, ShoppingCartPage::$total);
}
De las cosas más interesantes que nos ofrece Codeception es su simpleza, ya que podemos leer cada línea de nuestro test casi como una oración en ingles. Por ejemplo:
$I->amOnPage(ShoppingCartPage::$URL);
estoy en la página del carrito de compras (/order
),
$I->selectOption(ShoppingCartPage::$product, 'Tetris');
selecciono la opción Tetris de los
productos, $I->fillField(ShoppingCartPage::$quantity, 5);
y lleno el campo cantidad con un 5,
$I->click(ShoppingCartPage::$addToCart);
cuando doy clic en el botón agregar al carrito,
$I->see(501.05, ShoppingCartPage::$firstItemPrice);
debería ver el valor 501.05 en el precio total del producto
(última celda de la primera fila de la tabla).
Con esta prueba estamos validando también el correcto funcionamiento de nuestro componentes de Flight, razón por la cuál
en el post anterior, no escribimos las pruebas usando Karma. El código de este ejemplo lo desarrollé en una máquina
virtual generada con PuPHPet, razón por la que describí como ejecutar las pruebas usando PhantomJS. Sólo que hay
un pequeño detalle que encontré. PhantomJS no tiene soporte para la función bind
de JavaScript, debido a la versión
de QtWebKit en la que está basado, y al parecer no tendrá solución hasta la versión 2 como se explica en este
issue. Podemos usar algunos polyfills para solucionar el problema, en el repo de este ejemplo puedes ver
como se incluye de manera condicional un snippet de código que tomé de las respuestas en el issue cuando estamos
en el ambiente de testing. Es importante señalar que este snippet en nuestro template sólo tiene sentido si usamos
PhantomJS, no lo necesitamos con ningún otro navegador.
Espero que este post te haya servido para darte una mejor idea de como funciona el testing de aceptación con Codeception y como complementa los otros tipos de testing que revisamos en posts anteriores. Agradeceré mucho tus sugerencias, críticas y quejas en los comentarios.
]]>La aplicación de ejemplo que desarrollamos en el post anterior puede crearse con Yeoman. Hay un generador para Flight que podemos instalar de manera global.
$ npm install -g generator-flight
Este generador nos permite crear aplicaciones, componentes, mixins y páginas
$ flight <app-name>
$ flight:component <component-name>
$ flight:mixin <mixin-name>
$ flight:page <page-name>
El generador instala algunas dependencias usando bower y otras usando npm
Con Bower instala:
Con npm instala:
Las dependencias que instala con npm son para poder ejecutar las pruebas unitarias de los componentes Flight con Jasmine. Las pruebas se ejecutan usando Karma que nos permite ejecutar las pruebas en un navegador en modo headless con PhantomJS.
En este post explicaré como ejecutar los specs para componentes de Flight sin Karma, ya que considero que no es necesario ejecutar pruebas en modo 'headless' para los componentes. En su lugar podemos escribir pruebas de aceptación con Codeception que incluyan el funcionamiento de los componentes (que también pueden hacerse 'headles' con PhantomJS). Lo cuál explicaré en el siguiente post.
La configuración que usaré resulta excesiva si es que ya probaste el generador y te dejó todo listo para usar Karma. Para hacer que las pruebas unitarias funcionen debemos hacer una combinación de paquetes de bower instalados con npm, ya que el objetivo es evitar el navegador y que nuestros tests sean lentos. Si no te interesa evitar el navegador puedes saltar a la parte de proveedores de datos y generación de datos para pruebas.
El primer problema es configurar un ambiente similar al de un navegador. Esto significa que variables como window
y
document
existan en el espacio de nombres global
de Node. Esto se puede lograr con los paquetes jsdom
y jQuery
# specs-runner.js
var jQuery;
var jsdom = require('jsdom');
// Setup window and document, jQuery will need them to work properly
global.window = jsdom.jsdom().parentWindow
global.document = global.window.document;
// Add jQuery and $ to the global space
jQuery = require('jquery');
global.jQuery = global.$ = jQuery;
El siguiente paso es configurar RequireJS, ya que en el navegador nuestros componentes lo usan. Debemos tener la
misma configuración en Node para que funcionen, y agregar la funcion define
al espacio global.
# specs-runner.js
var requirejs = require('requirejs');
// Use the same value you use in the browser for 'paths' key.
requirejs.config({
baseUrl: './web/js',
nodeRequire: require,
paths: {
'flight': 'vendor/flight',
'store': 'src/store',
'component': 'src/component'
}
});
global.define = requirejs;
Debemos también instalar y configurar Jasmine para Node en su versión beta 4, ya que es la que usa Jasmine en su versión 2 y que necesitamos para poder usar Jasmine para jQuery. Debemos agregar algunas variables y funciones al espacio global para que funcionen igual que en el navegador.
# specs-runner.js
var jasmine;
// Setup Jasmine
jasmine = require('jasmine-node/lib/jasmine-node/jasmine-loader.js');
global.jasmine = jasmine;
// map jasmine.Env to global namespace
jasmineEnv = global.jasmine.getEnv();
for (key in jasmineEnv) {
if (jasmineEnv[key] instanceof Function) {
global[key] = jasmineEnv[key];
}
};
global.jasmine.addMatchers = jasmineEnv.addMatchers;
El siguiente paso es configurar Jasmine para Flight. Al igual que en los otros casos es necesario registrar algunas funciones en el espacio global.
# specs-runner.js
var jasmineFlight;
jasmineFlight = require('jasmine-flight');
// map jasmine-flight methods to global namespace
for (key in jasmineFlight) {
if (jasmineFlight[key] instanceof Function) {
global[key] = jasmineFlight[key];
}
};
Por último para poder crear 'spies' para eventos debemos usar Jasmine para jQuery y agregar spyOnEvent
al espacio
global.
# specs-runner.js
jasminejQuery = require('jasmine-jquery/lib/jasmine-jquery');
global.spyOnEvent = window.spyOnEvent;
En el caso de Jasmine para Flight, no pude configurarlo para que use la función require
de RequireJS en lugar del
require
de Node. Si sabes de alguna forma te agredeceré que lo expongas en los comentarios. Así que el demo usa un
fork mio donde reemplazo las apariciones de require
por requirejs
. Para esto debemos agregar la función al espacio
global de nombres global.requirejs = requirejs
. Así, el contenido del archivo package.json
sería el siguiente:
{
"name": "flight_demo",
"version": "1.0.0",
"devDependencies": {
"jasmine-flight": "https://github.com/MontealegreLuis/jasmine-flight/archive/no_browser.tar.gz",
"jasmine-jquery": "https://github.com/velesin/jasmine-jquery/archive/2.0.5.tar.gz",
"jasmine-node": "^2.0.0-beta4",
"jquery": "^2.1.1",
"jsdom": "^1.3.1",
"requirejs": "~2.1.11"
},
"scripts": {
"test": "node ./specs-runner.js"
}
}
Puedes revisar el contenido completo del archivo specs-runner.js
aquí.
Cuando hacemos tests a los componentes de Flight es muy importante hacer pruebas a la interfaz del componente y no a su comportamiento interno (es una recomendación que se puede aplicar al testing en general). Esto asegura que no tengamos que modificar las pruebas cada que modificamos el código del componente. Desde el punto de vista de la interfaz, los componentes de flight se suscriben a eventos y en ocasiones, como respuesta, publican eventos, esa es su interfaz.
Jasmine Flight nos proporciona métodos para crear specs para componentes de Flight. La primera diferencia con un spec
de Jasmine tradicional es que reemplazamos describe
por la función describeComponent
. Dentro de describeComponent
en el beforeEach
podemos llamar al método setupComponent
que nos permite pasar a nuestro componente los valores de
sus atributos, de forma similar al método attachTo
. En nuestro ejemplo creamos dos fakes uno para catalog
y
otro para cart
. El spec más simple que podemos generar verifica que el componente esté definido (toBeDefined
).
# web/js/spec/component/DataShoppingCart.spec.js
describeComponent('component/DataShoppingCart', function () {
beforeEach(function () {
this.setupComponent({
catalog: {allProducts: function(){}, productOfId: function(){}},
cart: {addItem: function() { return {} }}
});
});
it('should be defined', function () {
expect(this.component).toBeDefined();
});
});
En el siguiente test verificamos que el componente publique el evento data.whenItemIsAddedToCart
cuando se publique
el evento ui.whenProductIsAdded
. Para lograrlo creamos un spy para el evento ui.whenProductIsAdded
. Disparamos
el evento ui.whenProductIsAdded
en el nodo HTML asociado con el componente, y verificamos que el componente publique
el evento esperado.
# web/js/spec/component/DataShoppingCart.spec.js
it("should listen for 'ui.whenProductIsAdded' events and trigger 'data.whenItemIsAddedToCart' event", function () {
spyOnEvent(this.$node, 'data.whenItemIsAddedToCart');
this.$node.trigger('ui.whenProductIsAdded', {});
expect('data.whenItemIsAddedToCart').toHaveBeenTriggeredOn(this.$node);
});
El componente también debe publicar el evento data.whenProductsAreLoaded
al ejecutar el método loadProducts
. El
código es similar solo que en lugar de disparar un evento en el nodo HTML del componente, ejecutamos el método y
verificamos que el evento haya sido publicado.
# web/js/spec/component/DataShoppingCart.spec.js
it("should trigger 'data.whenProductsAreLoaded' event when method 'loadProducts' is executed", function () {
spyOnEvent(this.$node, 'data.whenProductsAreLoaded');
this.component.loadProducts({});
expect('data.whenProductsAreLoaded').toHaveBeenTriggeredOn(this.$node);
});
Para probar nuestro componente de interfaz, necesitaremos un fixture de HTML que pasaremos como primer argumento
al método setupComponent
. Este fixture reemplaza al nodo HTML asociado al componente que Jasmine Flight crea por
default (el cual es un div
). Lo necesitamos porque nuestro componente de interfaz busca elementos HTML con IDs
específicos que necesitamos pasar a nuestro spec para que funcione.
El primer test verifica que el componente actualice el HTML de la tabla que contiene los elementos del carro de compras
cada vez que se publique el evento data.whenItemIsAddedToCart
.
# web/js/spec/component/UiShoppingCart.spec.js
describeComponent('component/UiShoppingCart', function () {
var itemRow = '<tr><td>Lightsaber</td><td>$20.00</td><td>2</td><td>$ 40.00</td></tr>';
var cartTotal = '<p>$40.00</p>';
beforeEach(function () {
this.setupComponent(
'<table><tbody></tbody><tr><td id="cart-total"></td></tr></table>', {
totalSelector: '#cart-total',
cartItemsSelector: 'tbody',
itemTemplate: {render: function() {return itemRow;}},
totalTemplate: {render: function() {return cartTotal;}}
});
});
it("should listen for 'data.whenItemIsAddedToCart' events and update the cart items HTML", function () {
this.component.trigger(document, 'data.whenItemIsAddedToCart', {});
expect(this.component.select('cartItemsSelector').html()).toEqual(itemRow);
});
});
El objetivo de un proveedor de datos es alimentar un test con varios valores de prueba para evitar repetir el código de
un spec varias veces. Investigando encontré este post que implementa una función using
que provee de datos a un spec.
Encontré también este segundo post donde se mueve la función using
fuera del spec y permite el uso de funciones
para alimentar el test con datos. Me gustó más el estilo del primer post, aunque es un poco antiguo (Jasmine 1.2), así
que terminé con una combinación de ambos ejemplos:
# web/js/spec/helpers/UsingHelper.js
global.using = function(name, values, func) {
for (var i = 0, count = values.length; i < count; i++) {
if (Object.prototype.toString.call(values[i]) !== '[object Array]') {
values[i] = [values[i]];
}
// Pass the name of the spec and its values to add them to their description
it.specName = name;
it.data = values[i];
func.apply(this, values[i]);
// Clear the extra data once it has been used
it.data = null;
it.specName = null;
}
}
var it_multi = function _it_multi(desc, func) {
var _data = [], _desc = desc;
// Check if the current spec was called inside a 'using' call
if (it.data) {
_data = it.data;
// Update the spec description
_desc = desc + ' (with ' + it.specName + ' using values [' + _data.toString() + '])';
}
jasmine.getEnv().it(_desc, function() {
return function() {
func.apply(func, _data);
}
});
};
if ( it && typeof it == 'function') {
it = it_multi;
}
Debemos incluir este archivo en specs-runner.js
para usarlo en nuestros specs. Tomemos como
ejemplo el método total
del módulo OrderItem
. El segundo argumento que pasamos a using
es un arreglo donde el
primer elemento representan valores para el precio unitario y la cantidad de productos que se agregan al carro y el
segundo argumento es el total que esperamos que calcule nuestro módulo.
describe('OrderItem', function () {
using(
'valid products',
[
[[2000, 4], 8000],
[[3000, 3], 9000],
[[1500, 5], 7500]
],
function(item, total) {
it('should calculate an item total price', function () {
var cartItem = new OrderItem(item[0], item[1]);
expect(cartItem.total()).toBe(total);
});
}
);
});
Sin embargo la salida que producen nuestros specs no es tan descriptiva como quisieramos.
should calculate an item total price (with valid products using values [2000,4,8000]) - 156 ms
should calculate an item total price (with valid products using values [3000,3,9000]) - 1 ms
should calculate an item total price (with valid products using values [1500,5,7500]) - 1 ms
Podemos mejorar la legibilidad de nuestros specs si convertimos nuestros valores en objetos y les agregamos un método
toString
.
var toString = function() {
return 'price: ' + this.product.unitPrice + ', quantity: ' + this.quantity;
};
var totalToString = function() {
return ' expecting total to be: ' + this.total;
}
describe('OrderItem', function () {
using(
'valid products',
[
[
{product: {unitPrice: 2000}, quantity: 4, toString: toString},
{total: 8000, toString: totalToString}
],
[ {product: {unitPrice: 3000}, quantity: 3, toString: toString},
{total: 9000, toString: totalToString}
],
[
{product: {unitPrice: 1500}, quantity: 5, toString: toString},
{total: 7500, toString: totalToString}
]
],
function(item, expected) {
it('should calculate an item total price', function () {
var cartItem = new OrderItem(item.product, item.quantity);
expect(cartItem.total()).toBe(expected.total);
});
}
);
});
Lo cual mejora notablemente la legibilidad de nuestros specs.
should calculate an item total price (with valid products using values [price: 2000, quantity: 4, expecting total to be: 8000]) - 133 ms
should calculate an item total price (with valid products using values [price: 3000, quantity: 3, expecting total to be: 9000]) - 1 ms
should calculate an item total price (with valid products using values [price: 1500, quantity: 5, expecting total to be: 7500]) - 1 ms
Crear los valores para los proveedores de datos es una tarea tediosa que podemos evitar usando un generador de datos como
Fake. Podemos poner de ejemplo un spec para el módulo ProductsCatalog
donde queremos generar productos para
verificar que podemos encontrarlos por su ID. En el ejemplo creamos una función buildProducts
que crea productos
con ID y nombres aleatorios (100 productos en nuestro spec). Esos datos se usan para verificar que un producto se puede
encontrar por ID.
define(['store/ProductsCatalog'], function(ProductsCatalog) {
var faker, catalog;
var buildProducts = function(amount) {
var i, products = [];
for (i = 1; i <= amount; i++) {
products[i] = {productId: faker.Helpers.randomNumber(10), name: faker.Lorem.words(2)};
}
return products;
};
beforeEach(function () {
catalog = new ProductsCatalog();
faker = require('Faker');
});
describe('ProductsCatalog', function () {
it('should find a product by its identifier', function () {
var products = buildProducts(100);
var expectedProduct = products[5]; // Fifth product
var product;
catalog.setProducts(products);
product = catalog.productOfId(expectedProduct.productId);
expect(product.productId).toEqual(expectedProduct.productId);
expect(product.name).toEqual(expectedProduct.name);
});
});
});
Espero que este post te sea de utilidad para realizar testing a componentes Flight y módulos en general. Si tienes algun comentario lo agradeceré mucho. Puedes revisar el código completo en este repo en Github. Si al probar el código algo no funciona y necesitas ayuda por favor deja tu pregunta aquí así más gente puede ayudarte y más se beneficiarán con la respuesta.
]]>vendor
. Composer basa su funcionamiento en dos conceptos
importantes paquetes y repositorios.
Existen varios tipos de repositorios:
packages.json
servido a través de HTTP, FTP, o SSH, que
contiene una lista con la información de los paquetes de ese repositorio.git
, svn
y hg
.zip
o un tar
.Si quieres aprender como funciona Composer revisa esta presentación de Rafael Dohms. Ahora, si tu interés es aprender a construir paquetes para PHP te recomiendo mucho este libro de Matthias Noback.
Satis es un generador estático de repositorios del tipo composer
, es de código abierto y básicamente te permite tener
una versión privada minimalista de Packagist. Es este post explicaré como instalar y configurar un repositorio de Satis
con acceso SSH.
Primero tienes que instalar Composer en el servidor donde quieres alojar tu repositorio de Satis. Supongamos que yo lo
quiero alojar en http://packages.montealegreluis.com
. Primero debo instalar Composer de forma global.
# replace 'satis_user' with your user
$ curl -s https://getcomposer.org/installer | php -- --install-dir=/home/satis_user/bin
$ mv ~/bin/composer.phar ~/bin/composer
$ chmod u+x ~/bin/composer
Lo siguiente es instalar Satis
$ composer create-project composer/satis --stability=dev --keep-vcs
Esto creará una carpeta satis
con el binario que nos permitirá crear nuestro repositorio. El primer paso es
definir un archivo de configuración (satis.json
) con la lista de paquetes privados que quieres usar.
Supongamos que tengo un repositorio privado en Github (https://github.com/ComPHPPuebla/dbal-fixtures
) con el paquete
comphppuebla/dbal-fixtures
que quiero usar en mi proyecto con una versión estable entre 1.0.0
y 2.0.0
.
{
"name": "My Repository",
"homepage": "http://packages.montealegreluis.com",
"repositories": [
{ "type": "vcs", "url": "https://github.com/ComPHPPuebla/dbal-fixtures" }
],
"require-all": true
}
La llave require-all
indica que queremos todas las versiones de todos los paquetes. Aquí hay ejemplos si
quieres hacer una selección más específica. Muy probablemente quieras poner el archivo satis.json
en su propio
repositorio en Git.
Para construir el repositorio a partir del archivo de configuración ejecutamos el siguiente comando:
$ php bin/satis build satis.json storage/
storage
es la carpeta donde se guardarán los archivos de nuestro repositorio.
Siguiendo con el ejemplo, una vez que tengo instalado el repositorio Satis, tengo que agregar al archivo composer.json
de mi proyecto la URL del repositorio Satis que acabo de generar.
{
"require": {
"php": ">=5.5",
"comphppuebla/dbal-fixtures": "~1.0",
},
"repositories": [
{
"type": "composer",
"url": "ssh2.sftp://packages.mandragora-web-systems.com/home/satis_user/satis/storage"
}
]
}
La URL ssh2.sftp://packages.mandragora-web-systems.com/home/satis_user/satis/storage
indica que accederemos a
Satis a través de SSH y que el archivo packages.json
que necesita Composer para saber cuáles son los paquetes que
aloja nuestro repositorio, está ubicado en la carpeta /home/satis_user/satis/storage
. Para que puedas tener acceso a
traves de SSH debes tener instalada la extension PECL SSH2.
Si no tienes instalada la extensión estos son los comandos para instalarla en Ubuntu.
$ apt-get install -y libssh2-1 libssh2-1-dev
$ apt-get install -y php-pear
$ pecl -d preferred_state=beta install ssh2
$ echo "extension=ssh2.so" >> /etc/php5/apache2/php.ini
Como el acceso es a través de SSH debemos indicar la ubicación de las llaves y el nombre de usuario que usaremos para
autenticarnos a través del valor de options
.
{
"repositories": {
"montealegreluis": {
"type": "composer",
"url": "ssh2.sftp://packages.mandragora-web-systems.com:/home/satis_user/satis/storage",
"options": {
"ssh2": {
"username": "satis_user",
"pubkey_file": "/home/luis/.ssh/id_rsa.pub",
"privkey_file": "/home/luis/.ssh/id_rsa"
}
}
}
}
}
Podemos configurar nuestro repositorio en el archivo composer.json
para cada proyecto. Sin embargo no resulta tan
práctico ya que, estamos poniendo rutas absolutas para la opción pubkey_file
y privkey_file
, cuyo valor es
/home/luis/.ssh/id_rsa.pub
y /home/luis/.ssh/id_rsa
respectivamente. Si quiero instalar el proyecto en otra
computadora y no tiene el usuario luis
tendré que modificar las rutas hacias las llaves.
Composer cuenta con un archivo de configuración global config.json
al que podemos agregar repositorios como el que
acabamos de crear de forma global. Si continuamos con el ejemplo en mi caso el archivo estaría en
/home/luis/.composer/config.json
.
Si quieres saber cuáles son tus configuraciones globales actuales puedes ejecutar el comando:
$ composer config -g -l
Así lo único que tienes que hacer es copiar y pegar las configuraciones de tu repositorio desde el archivo composer.json
de tu proyecto al archivo config.json
Para que funcione el ejemplo debes agregar tus llaves al servidor donde se encuentra el repositorio Satis. Si aún no tienes tus llaves SSH las puedes crear así:
$ ssh-keygen -t rsa -C "your_email@example.com"
Y las puedes copiar a tu servidor Satis así:
$ ssh-copy-id -i ~/.ssh/id_rsa.pub satis_user@packages.montealegreluis.com
Una vez que tienes todo configurado sólo es cuestión de ejecutar el install
de Composer y tu paquete se instalará
desde tu repositorio Satis.
$ composer install
Espero que el post te sirva para configurar tu propio repositorio Satis. Si tienes algun comentario o alguna experiencia que quieras compartir te lo agradeceré mucho.
]]>Flight usa los eventos del DOM como 'proxies' para los eventos de componentes, lo cual le da mayor flexibilidad, ya que:
ḑocument
) o puede solo escuchar eventos
pertenecientes a un nodo del DOM (#price
).clientProceedToCheckout
) y eventos nativos (p. ej. click
).Otra ventaja de Flight es que podemos distinguir dos tipos de componentes principales: componentes de interfaz y componentes de datos. De este modo las aplicaciones de Flight pueden entenderse como colecciones de componentes.
Para ejemplificar el uso de Flight desarrollaremos una pequeña interfaz de 'carro de compras', con un formulario con productos y una tabla donde se mostrarán los productos que vamos agregando, así como el total a pagar.
Antes de iniciar con los componentes, pensemos en los módulos que conforman nuestra aplicación. Necesitaremos un catálogo de productos para que los clientes puedan seleccionar lo que quieren comprar. Nuestro catálogo debe poder recuperar todos los productos disponibles y debe poder buscar un producto en particular.
// web/js/store/ProductsCatalog.js
var ProductsCatalog = function() {
var products;
this.load = function(options) {
var productsLoaded = options.callback || this.setProducts;
options.request.ajax({
url: options.url,
dataType: 'json',
async: false,
success: productsLoaded
});
};
this.setProducts = function(allProducts) {
products = allProducts;
};
this.productOfId = function(id) {
var i;
for (i in products) {
if (products[i].productId == id) {
return products[i];
}
}
};
this.allProducts = function() {
return products;
}
return this;
};
Nuestro carro de compras manejará una única orden de compra, la cual estará compuesta por varios elementos (items) que contienen los datos del producto y la cantidad que se desea comprar. Cada item debe poder calcular el total a pagar por la cantidad de productos seleccionados.
// web/js/store/OrderItem.js
var OrderItem = function(product, quantity) {
this.productId = function() {
return product.productId;
};
this.productName = function() {
return product.name;
};
this.unitPrice = function() {
return product.unitPrice;
}
this.quantity = function() {
return quantity;
}
this.total = function() {
return product.unitPrice * quantity;
};
return this;
};
Por último, nuestro carro de compra, al cual podemos agregar o quitar productos y le podemos consultar cuál será el total a pagar.
// web/js/store/ShoppingCart.js
var ShoppingCart = function() {
var orderItems = [];
this.addItem = function(product, quantity) {
var item = new OrderItem(product, quantity);
orderItems[item.productId()] = item;
return item;
}
this.removeItem = function(item) {
orderItems.splice(item.productId(), 1);
}
this.total = function() {
var index, total = 0;
for (index in orderItems) {
total += orderItems[index].total();
}
return total;
}
return this;
};
Una ventaja de diseñar los módulos antes que los componentes Flight, es que podemos hacer pruebas unitarias sin necesidad de asociarlos a ninguna interfaz gráfica. Además de que si algún día decidimos dejar de usar Flight nuestra lógica no se encuentra contenida en el código del framework.
Flight utiliza RequireJS para la carga de módulos y jQuery para el manejo de eventos y manipulación del DOM. La definición de un componente en Flight es de la siguiente forma:
define(['vendor/flight/lib/component'], function(defineComponent) {
var Component = function() { /* ... */ };
return defineComponent(Component);
}
Flight asocia un componente a un nodo del DOM a través del método attachTo
el cuál recibe dos argumentos, el primero
es un selector válido, y el segundo es un objeto con los atributos que queremos que tenga nuestro componente.
Generalmente los atributos son selectores de nodos dentro del nodo principal (el selector de un botón submit dentro de
un form, por ejemplo) u objetos que queremos inyectar como dependencia a nuestro componente, por ejemplo un objeto
ProductsCatalog
.
Component.attachTo('#element-id', {
selector: '#some-id',
dependency: module,
});
Primero identificaremos qué componentes de datos y qué componentes de interfaz serán necesarios. De inicio tenemos dos
componentes de interfaz uno para el formulario (UiOrderItem
) y otro para la tabla con los productos agregados
(UiShoppingCart
) y un componente de datos (DataShoppingCart
).
El componente UiOrderItem
tiene dos tareas, una es actualizar las opciones del elemento select
del formulario,
para la cual se suscribe al evento data.whenProductsAreLoaded
y la otra es notificar cuando se agrega un elemento al
carrito, para lo cual publica el evento ui.whenProductIsAdded
, este evento se emite en el evento submit
del
formulario.
Si consideramos que nuestro formulario es el siguiente:
<!-- web/order.html -->
<form id="item-form">
<label for="product">Product</label>
<select name="product" id="product">
<option value="">Choose a product</option>
</select>
<label for="quantity">Quantity</label>
<input type="text" name="quantity" id="quantity">
<button type="submit">Add to cart</button>
</form>
Estaríamos asociando nuestro componente UiOrderItem
al formulario con ID item-form
. También necesitamos indicarle al
componente cuál es el selector para productos (y que se puedan actualizar los elementos option
del select
) y el
selector del input
de cantidad para indicar al carro de compras cuántos productos quiere nuestro cliente.
Para generar los elementos option
usaremos un template de Twig.js con el siguiente contenido:
{# web/js/templates/products.html.twig #}
<option value="">Choose a product</option>
{% for product in products %}
<option value="{{ product.productId }}">{{ product.name }}</option>
{% endfor %}
Flight utiliza módulos a los que llama páginas, donde se cargan los componentes que usará la aplicación y se inicializan todos nuestros módulos y objetos. Así, el código para asociar nuestro componente quedaría de la siguiente forma:
// web/js/page/ShoppingCartPage.js
define(function (require) {
var view = require('twig');
var UiOrderItem = require('component/UiOrderItem');
var ShoppingCartPage = function() {
this.init = function() {
view.twig({href: '/js/templates/products.html.twig', async: false, id: 'products'});
UiOrderItem.attachTo('#item-form', {
quantitySelector: '#quantity',
productSelector: '#product',
productsTemplate: view.twig({ref: 'products'})
});
};
};
return ShoppingCartPage;
};
Dentro de nuestro componente usamos el método attributes
para declarar los atributos (selectores o dependencias) de
nuestro componente. El objeto que pasamos a atributes
declara valores default para las propiedades diferentes de
null
(p. ej. productsSelector: '#product'
indica que el valor default del atributo es #product
), mientras que un
valor nulo indica que el atributo es obligatorio y que debemos proporcionar su valor a través del
método attachTo
(p. ej. productsTemplate: null
indica que el atributo es obligatorio).
// web/js/component/UiOrderITem.js
var UiOrderItem = function() {
this.attributes({
/* All attributes are mandatory */
quantitySelector: null,
productSelector: null,
productsTemplate: null
});
/* ... */
};
Los métodos de un componente que se suscriben a un evento reciben dos argumentos.
event
(para el caso de los
eventos nativos del DOM).Para nuestro ejemplo, el objeto que recibe el método refreshProductsOptions
como segundo argumento contiene los
productos que se mostrarán en el select
(data.products
).
// web/js/component/UiOrderITem.js
var UiOrderItem = function() {
/* .. */
this.refreshProductsOptions = function(event, data) {
this
.select('productSelector')
.html(this.attr.productsTemplate.render({products: data.products}));
};
/* .. */
};
El nodo asociado a un componente en Flight es accesible a través de dos variables.
node
que hace referencia al elemento del DOM y$node
que es su equivalente en jQuery.El método select
de un component es el equivalente de $node.find(attributeName)
. En nuestro caso
this.select('productSelector')
es equivalente a this.$node.find('#product')
ya que el valor de
this.attr.productSelector
es #product
.
El método addItem
se suscribirá al evento submit
de nuestro formulario y publicará el evento ui.whenProductIsAdded
pasando como datos el ID del producto y la cantidad de productos que ingresó nuestro cliente.
// web/js/component/UiOrderITem.js
var UiOrderItem = function() {
/* ... */
this.addItem = function (event) {
event.preventDefault();
this.trigger('ui.whenProductIsAdded', {
productId: this.select('productSelector').val(),
quantity: this.select('quantitySelector').val()
});
};
/* .. */
};
Para registrar los eventos de un componente usamos el método after
de Flight. En nuestro ejemplo, el método
refreshProductsOptions
se suscribe al evento data.whenProductsAreLoaded
y el método addItem
se suscribe al evento
submit
// web/js/component/UiOrderITem.js
var UiOrderItem = function() {
/* ... */
this.after('initialize', function () {
this.on(document, 'data.whenProductsAreLoaded', this.refreshProductsOptions);
this.on('submit', this.addItem);
});
/* ... */
};
El componente UiShoppingCart
no publica ningún evento, pero se suscribe a dos eventos. El primero es
data.whenItemIsAddedToCart
al cuál deberá responder agregando una fila a la tabla. El segundo es
data.whenCartTotalHasChanged
al cual deberá responder actualizando la celda de total.
Si consideramos que la tabla que contendrá los productos en nuestro carro de compras es el siguiente:
<!-- web/order.html -->
<table id="items-table">
<thead>
<tr>
<th>Product</th>
<th>Unit price</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td id="cart-total"></td>
</tr>
</tfoot>
</table>
Nuestro componente quedaría registrado de la siguiente forma (el código de los templates esta en item.html.twig y cart-total.html.twig):
// web/js/page/ShoppingCartPage.js
view.twig({href: '/js/templates/item.html.twig', async: false, id: 'item'});
view.twig({href: '/js/templates/cart-total.html.twig', async: false, id: 'total'});
UiShoppingCart.attachTo('#items-table', {
tableBodySelector: 'tbody',
totalSelector: '#cart-total',
itemTemplate: view.twig({ref: 'item'}),
totalTemplate: view.twig({ref: 'total'})
});
Y el código de nuestro componente quedaría de la siguiente forma:
// web/js/component/UiShoppingCart.js
var UiShoppingCart = function() {
this.attributes({
totalSelector: null,
tableBodySelector: null,
itemTemplate: null,
totalTemplate: null
});
this.appendProduct = function (event, data) {
this.select('tableBodySelector').append(this.attr.itemTemplate.render({item: data.item}));
}
this.updateTotal = function (event, data) {
this.select('totalSelector').html(this.attr.totalTemplate.render({cart: data.cart}));
}
this.after('initialize', function() {
this.on(document, 'data.whenItemIsAddedToCart', this.appendProduct);
this.on(document, 'data.whenCartTotalHasChanged', this.updateTotal);
});
};
Por último nuestro componente de datos:
Nuestro componente de datos suscribe el método addItemToCart
al evento ui.whenProductIsAdded
. El método addItemToCart
agrega al carro de compras un producto que recupera del catálogo a través de su ID, y después publica los eventos
data.whenItemIsAddedToCart
y data.whenCartTotalHasChanged
. El método loadProducts
recupera los productos del
catálogo y publica el evento data.whenProductsAreLoaded
.
// web/js/component/DataShoppingCart.js
var DataShoppingCart = function() {
this.attributes({
catalog: null,
cart: null
});
this.loadProducts = function() {
this.trigger('data.whenProductsAreLoaded', {products: this.attr.catalog.allProducts()});
};
this.addItemToCart = function(event, item) {
var productItem = this.attr.cart.addItem(
this.attr.catalog.productOfId(item.productId), item.quantity
);
this.trigger('data.whenItemIsAddedToCart', {item: productItem});
this.trigger('data.whenCartTotalHasChanged', {cart: this.attr.cart});
};
this.after('initialize', function () {
this.on('ui.whenProductIsAdded', this.addItemToCart);
this.loadProducts();
});
};
Este componente no tiene atributos que hagan referencia a la interfaz (selectores o templates). En su lugar tiene como
dependencias los módulos con la lógica de nuestra aplicación (ProductsCatalog
y ShoppingCart
).
// web/js/page/ShoppingCartPage.js
var $ = require('jquery');
var ShoppingCart = require('store/ShoppingCart');
var ProductsCatalog = require('store/ProductsCatalog');
var cart = new ShoppingCart();
var catalog = new ProductsCatalog();
catalog.load({request: $, url: '/products'});
DataShoppingCart.attachTo(document, {
catalog: catalog,
cart: cart
});
Podemos resumir las relaciones entre componentes de nuestra aplicación de la siguiente forma. Las flechas indican qué componente publica o se suscribe un evento y a través de qué método. Los componentes aparecen encerrados en círculos, los eventos aparecen dentro de rectángulos redondeados y los métodos aparecen subrayados.
Por último, como la mayoría de las aplicaciones que usan RequireJs la aplicación se inicializa en un archivo main.js
donde llamamos al método init
de nuestra página.
// web/js/main.js
require([
'vendor/flight/lib/compose',
'vendor/flight/lib/registry',
'vendor/flight/lib/advice',
],
function (compose, registry, advice) {
compose.mixin(registry, [advice.withAdvice]);
require(['page/ShoppingCartPage'], function (ShoppingCartPage) {
var page = new ShoppingCartPage();
page.init();
});
}
);
Espero que este post te de una idea del uso y ventajas de Flight. En el siguiente post haremos el testing de nuestros componentes y módulos. Si tienes algun comentario lo agradeceré mucho. Puedes revisar el código completo en este repo en Github. Si al probar el código algo no funciona y necesitas ayuda por favor deja tu pregunta aquí así más gente puede ayudarte y más personas se beneficiarán con la respuesta.
]]>El ejemplo trata de los clásicos selects
encadenados, donde al seleccionar un valor del primer select
se
actualizan los valores del segundo, usando la típica relación estados-ciudades.
El código que generalmente escribimos para tener este comportamiento es más o menos así:
<form role="form">
<!-- ... -->
</form>
<script>
$('#states').on('change', function() {
var optionTemplate = '<option value="{value}">{label}</option>';
var options='', i, citiesCount;
$.ajax({
url: '/app/cities.json',
dataType: 'json',
data: {'state': $(this).val()},
success: function(cities) {
citiesCount = cities.length
for (i = 0; i < citiesCount; i++) {
options += optionTemplate
.replace('{value}', cities[i].value)
.replace('{label}', cities[i].label);
}
$('#cities').html(options);
}
});
});
</script>
Para simplificar el ejemplo, /app/cities.json
es un archivo con extensión .json
que tiene un contenido similar al
siguiente.
[
// ..
{
"value": "114",
"label": "Puebla"
},
// ..
{
"value": "207",
"label": "Zacapoaxtla"
}
]
Este código si bien no es difícil de entender, resulta muy difícil de testear. El primero de los problemas que encontramos es que el código JavaScript está dentro del HTML y resulta imposible testearlo por separado. Segundo aunque estuviera en un archivo separado no cuenta con una interfaz pública que podamos validar a través de pruebas. Un punto más en contra es que tampoco es posible hacer pruebas a las funciones anónimas que utiliza. Además el uso de manejadores de eventos que actualizan el DOM es una muestra de la mezcla de responsabilidades dentro del código. Para terminar, el uso de solicitudes XHR sin un mecanismo que nos permita saber cuando terminaron su ejecución, complica aún más las cosas.
Podemos comenzar reemplazando el código que genera HTML concatenando cadenas, por una librería de plantillas que genere
los elementos option
de nuestro select. Para nuestro ejemplo usaremos Twig.js que es una implementación en
JavaScript de Twig.
Para generar los elementos option
con Twig usamos una plantilla que itera sobre los resultados que nos devuelve
nuestra llamada AJAX generando nuestros elementos option
.
{% for city in cities %}
<option value="{{ city.value }}">{{ city.label }}</option>
{% endfor %}
Para poder usar la plantilla debemos cargarla primero.
twig({href: '/js/app/templates/cities.html.twig', id: 'cities', async: false});
twig({ref: 'cities'}).render({cities: cities});
Así nuestra llamada AJAX quedaría de la siguiente forma:
$.ajax({
url: '/app/cities.json',
dataType: 'json',
data: {'state': $(this).val()},
success: function(cities) {
twig({href: '/js/app/templates/cities.html.twig', id: 'cities', async: false});
$('#cities').html(twig({ref: 'cities'}).render({cities: cities}));
}
});
El siguiente paso es convertir la función anónima que se ejecuta al finalizar la solicuitud AJAX (success
) en un
módulo. Con esto comenzaremos a dar una interfaz pública a nuestro código para poder testearlo por separado.
var ShippingForm = function($city, view) {
'use strict';
this.refreshOptions = function(cities) {
$city.html(view({ref: 'cities'}).render({cities: cities}));
};
return this;
};
Así en lugar de usar una función anónima podemos usar ShippingForm.refreshOptions
como callback.
var form = new ShippingForm($('#cities'), twig);
$.ajax({
url: '/app/cities.json',
dataType: 'json',
data: {'state': $(this).val()},
success: form.refreshOptions
});
La lógica relacionada con el evento change
de nuestro select
de estados también es una función anónima que podemos
mover dentro de nuestro módulo (ShippingForm.getCities
).
var ShippingForm = function($city, view, $state, $, citiesUrl, refreshOptions) {
'use strict';
var form = this;
//...
this.getCities = function() {
var stateId = $state.val();
if (!stateId) {
return;
}
refreshOptions = refreshOptions || form.refreshOptions;
$.ajax({
url: citiesUrl,
dataType: 'json',
data: {'state': stateId},
success: refreshOptions
});
};
};
De nuevo reemplazamos la función anónima con el método getCities
de nuestro módulo.
var form = new ShippingForm($('#cities'), twig, $('#states'), $, '/app/cities.json');
$('#states').on('change', form.getCities);
Por útlimo podemos encapsular la asociación del evento change
con el método getCities
dentro de nuestro módulo, lo
cuál nos dará oportunidad de testear todo el código que teníamos al inicio. Es importante mencionar que estamos
inyectando todas nuestras dependencias (DOM y solictudes XHR) a fin de poder reemplazarlas por dobles
en nuestras pruebas.
var ShippingForm = function($city, view, $state, $, refreshOptions) {
//...
};
var form = new ShippingForm($('#cities'), twig, $('#states'), $);
form.init();
Nuestro siguiente paso es mover nuestro módulo a un archivo separado. Para esto utilizaré RequireJS que es un
loader
de módulos y archivos, optimizado para trabajar en navegadores. RequireJS utiliza un solo archivo como punto
de entrada para nuestra aplicación, al cual generalmente se le nombra main.js
.
<script data-main="js/app/main" src="js/vendor/requirejs/require.js"></script>
El archivo main.js
se usa para configurar las dependencias iniciales de nuestra aplicación, en nuestro caso
jQuery y Twig. También es el encargado de iniciar nuestra aplicación a través de un módulo escrito por nosotros llamado
app
.
require.config({
paths: {
'jquery': '../vendor/jquery/dist/jquery',
'twig': '../vendor/twig.js/twig.min'
},
baseUrl: '/js/app'
});
require(['./app'], function(app) {
app.init();
});
El módulo app
hace la carga inicial de dependencias, incluidos nuestros módulos (ShippingForm
).
define(['twig', 'jquery', './src/ShippingForm'], function (view, $, ShippingForm) {
'use strict';
var app = {};
app.init = function() {
var form;
view.twig({
href: '/js/app/templates/cities.html.twig',
async: false,
id: 'cities'
});
form = new ShippingForm($, $('#states'), $('#cities'), view, '/app/cities.json');
form.init();
};
return app;
});
Para las pruebas usaré Jasmine que es un framework para testing del tipo BDD que se destaca por tener una sintaxis muy fácil de entender. Jasmine utiliza suites, que son un conjunto de casos de pruebas llamados specs.
Usaremos npm para instalar las dependencias. npm
utiliza el archivo package.json
para determinar cuales son
las dependencias a instalar.
{
"name": "@montealegreluis/testing",
"license": "MIT",
"devDependencies": {
"jasmine-node": "~1.14",
"requirejs": "~2.1"
},
"scripts": {
"test": "node ./specs-runner.js"
}
}
Una vez definidas nuestras dependencias (Jasmine y RequireJS), las instalamos.
$ npm install
Para poder ejecutar nuestras pruebas, necesitamos configurar Jasmine y RequireJS para que funcionen de manera similar a como funcionan en un navegador, para esto necesitamos un runner especial, el cual está basado en la configuración de este repo de Zaworski.
Con este archivo (el cuál se configura en la llave scripts
del archivo package.json
) podemos ya ejecutar nuestros
tests desde la consola usando npm
$ npm test
Nuestra suite verifica los métodos del módulo ShippingForm
. Para esto debemos crear el archivo
js/app/spec/ShippingForm.spec.js
. Nuestro primer spec verifica que se inicialice correctamente el evento change
de nuestro select
de estados. Para esto creamos un doble del tipo spy para nuestro elemento $state
que
verifica que se llame al método on
con los parámetros correctos.
define(['src/ShippingForm'], function(ShippingForm) {
'use strict';
describe('ShippingForm', function () {
it('should initialize onchange event', function () {
var $state = jasmine.createSpyObj('state', ['on']);
var form = new ShippingForm({}, $state);
form.init();
expect($state.on).toHaveBeenCalledWith('change', form.getCities);
});
});
});
Nuestro segundo spec verifica que si el valor del elemento seleccionado en nuestro select
es vacío, la llamada AJAX
no se ejecute. En esta ocasión se crea un stub para $state
que nos devuelva una cadena vacía, que nos permita
verificar en el spy para $
que el método ajax
no se llamó.
it('should skip getting the cities if there is no current state selected', function () {
var $state = {
val: function() {}
};
var $ = jasmine.createSpyObj('$', ['ajax']);
var form = new ShippingForm($, $state);
spyOn($state, 'val').andReturn('');
form.getCities();
expect($state.val).toHaveBeenCalled();
expect($.ajax).not.toHaveBeenCalled();
});
Nuestro siguiente spec verifica que si se selecciona un valor no vacío en el select
de estado, se realice la llamada
AJAX que devuelva las ciudades. Para esto creamos un spy de la función refreshOptions
para verificar que se llame al
callback de éxito al terminar la solicitud AJAX, también necesitamos un stub de $state
para que devuelva un valor no
vacío y que el spy de $
ejecute el método ajax
.
it('should get the cities when a state is selected', function () {
var $state = {
val: function() {}
};
var $ = {
ajax: function(options) {
options.success.call();
}
};
var refreshOptions = jasmine.createSpy('refreshOptions');
var form = new ShippingForm($, $state, {}, {}, '/app/cities.json', refreshOptions);
spyOn($state, 'val').andReturn('21');
spyOn($, 'ajax').andCallThrough();
form.getCities();
expect($state.val).toHaveBeenCalled();
expect($.ajax).toHaveBeenCalled();
expect(refreshOptions).toHaveBeenCalled();
});
Por último verificamos que el método refreshOptions
de nuestro módulo funcione correctamente. Para esto creamos un spy
de twig
y un spy de $city
para verificar las llamadas a los métodos render
y html
respectivamente.
it('should refresh the cities options', function () {
var $city = jasmine.createSpyObj('city', ['html']);
var view = {
twig: function() {
return {
render: function() {}
};
}
}
var form = new ShippingForm({}, {}, $city, view, '/app/cities.json');
spyOn(view, 'twig').andCallThrough();
form.refreshOptions([{value: 12, label: 'Puebla'}]);
expect($city.html).toHaveBeenCalled();
expect(view.twig).toHaveBeenCalled();
});
Espero que este post te sea de utilidad para realizar testing a código JavaScript. Si tienes algun comentario lo agradeceré mucho. Puedes revisar el código completo en este repo en Github. Si al probar el código algo no funciona y necesitas ayuda por favor deja tu pregunta aquí así más gente puede ayudarte y más se beneficiarán con la respuesta.
]]>