Hace tiempo, utilizamos esta técnica para analizar y mejorar el trabajo en equipos de desarrollo en la empresa en la que estaba trabajando. A partir de esa experiencia, preparé un guion para ayudar a otros equipos a llevar a cabo el mismo análisis. Hace unos días me encontré con ese material y me pareció que podría ser interesante hacer un artículo sobre el tema.
Voy a traducir constraint como limitador, para acentuar el aspecto de que son circunstancias que limitan la capacidad de un equipo para lograr sus objetivos. Pero podríamos usar sinónimos como restricciones, condicionantes, elementos bloqueantes, etc.
La teoría de los limitadores (TOC) es un paradigma de gestión que considera que cuando un equipo no consigue más de sus metas es a causa de un número pequeño de condicionantes o limitadores. Estas limitaciones se pueden ordenar de acuerdo a cuanto obstaculizan la productividad del equipo. De esta forma, se pueden organizar las medidas que nos ayudan a remediarlas de forma que maximicemos su impacto.
Cuando un equipo observa que no consigue sus objetivos de sprint, que no termina tareas o no consigue culminar proyectos, lo más probable es que se deba principalmente a dos o tres condicionantes o limitadores. El modelo nos dice que si identificamos estos limitadores y los ordenamos por su capacidad de bloqueo, podremos tomar medidas que impacten de forma significativa mejorando la productividad.
Siempre hay al menos un limitador, y la TOC usa un proceso de enfoque para identificarlo y reestructurar la organización en relación con ella. TOC adopta el dicho “una cadena es tan fuerte como su eslabón más débil”. En otras palabras: la productividad de un equipo no puede crecer más allá de lo que permitan sus limitadores.
Los limitadores se llaman así porque imponen un límite a lo que podemos conseguir. Imagina un equipo de desarrollo que, por lo que sea, no utiliza un gestor de versiones. En ese caso, asegurar que todo el mundo trabaja sobre la última versión del código puede ser una tarea dantesca y, sobre todo, muy limitadora de la productividad. No puedo imaginar el coste de una metodología como esta. Seguramente tendrán otros problemas, pero la capacidad bloqueante de carecer de esta herramienta es brutal. Y el beneficio de introducirla, proporcional.
El efecto de cada limitador determina la ganancia de productividad que podemos conseguir, por lo que nos interesa identificar y combatir el limitador más bloqueante que tengamos. En el ejemplo anterior, me atrevería a decir que el 50% del tiempo se iría en la gestión de todos los problemas derivados de mantener coherentes las versiones de las distintas desarrolladoras y el producto desplegado.
Hablando de desplegar, en esta situación me imagino que el proceso será también lento y tortuoso. Pero en ese caso, puede que el tiempo empleado sea un 10%. Es decir, es un limitador más pequeño que, por otra parte, es dependiente del anterior. Juntos hacen que la productividad real del equipo sea solo de un 40% de la posible.
Incluso si fuesen independientes, atacando ese 10% de tiempo dedicado al despliegue, la mejora sería relativamente pequeña, porque el principal bloqueador es el otro problema.
En consecuencia, es preferible dedicar el esfuerzo al limitador más grande: percibiremos una mejora mayor y puede, incluso, que facilitemos eliminar otros o al menos reducir sus efectos.
Existen infinidad de posibles bloqueadores a la productividad y muchas veces dependen de las circunstancias específicas de cada equipo o empresa, y este análisis puede ayudarnos a detectarlos y ponerles solución.
Un conjunto de ideas accionables que puedan ayudar de forma efectiva a incrementar la productividad del equipo mediante la eliminación, reducción o control del elemento limitador.
En mi opinión este análisis puede aplicarse de dos formas:
Para este análisis, y dependiendo del alcance, pueden hacerse dos o tres sesiones de entre 50 y 60 minutos. Si el ámbito es de un problema específico, una única sesión bien enfocada debería ser suficiente.
Notas:
Si encuentras muchos bloqueos, es más que posible que estén relacionados entre sí de alguna forma. Por tanto:
El objetivo es quedarnos con los tres o cuatro más importantes por su efecto. Más allá de este número es muy posible que el efecto real sea casi inapreciable
Una vez que has identificado los limitadores tienes que ordenarlos del que sea más bloqueante al que menos. Formas de evaluar esto pueden ser:
Obtendrás el mayor beneficio corrigiendo el primero y más limitante. Atacar los otros puede ayudar también, pero el efecto observable será menor.
El último punto del proceso es encontrar soluciones para eliminar o gestionar el limitador. Y estas implican distintos niveles. En el primer nivel y segundo nivel (Exprimir y Subordinar), intentamos lidiar con el problema de productividad inmediato, por ejemplo, un proyecto concreto que no está saliendo.
En este nivel, lo que buscamos es atacar el limitador sin acudir a otros recursos y tratar de sacar adelante el trabajo actual más importante.
Decide qué puedes hacer en otras áreas para reducir el coste e impacto de la limitación, asegura que los demás procesos puedan contribuir a reducir el cuello de botella.
Haz cambios mayores en el sistema para eliminar o romper la limitación, asignando o redistribuyendo recursos personales, técnicos o de conocimiento.
Otras acciones podrían en este nivel podrían ser:
Recuerda, a veces simplemente no puedes eliminar un limitador y tienes que aprender a vivir con él.
Imagina que tu empresa ha organizado los equipos de desarrollo por especialidad (frontend, backend, sistemas, bases de datos…). Seguramente, habrá que hacer un esfuerzo extra para sacar adelante proyectos que requieren la coordinación entre ellos. Habrá tiempos de espera mientras un equipo desarrolla su parte y otros no pueden avanzar sin ella. Asímismo habrá problemas al juntar elementos para crear la solución final.
En este escenario, el problema de cuello de botella puede haber sido identificado por un equipo que ha observado que no puede avanzar mientras espera que otro equipo haga una entrega necesaria.
En este caso podríamos, entre otras cosas:
Siempre aparecerá un nuevo limitador. Nunca te des por satisfecha.
Como hemos visto, lo normal sería haber identificado varios limitadores, así que una vez que hemos conseguido atacar uno y mejorar en ese aspecto, podríamos plantearnos abordar el siguiente.
Pero también podrían descubrirse otros nuevos que estaban ocultos por el anterior.
Una vez hemos identificado los limitadores y hemos definido posibles formas de abordarlos es el momento de ponerlos en práctica.
Lo ideal sería tener algún tipo de medida objetiva que nos permita identificar el avance. Las métricas DORA pueden servir para cuestiones generales de delivery, pero podríamos introducir otras en función de nuestros objetivos particulares:
Esta taxonomía de limitadores puede ser útil para identificarlos. He añadido algunos ejemplos que ilustran cada una de ellas. A veces, cuando el equipo no tiene claro cómo identificar limitadores, es buena idea repasar estas categorías.
Políticas: los limitadores de este tipo son causadas por los procesos y políticas de la empresa u organización. Esto incluye regulaciones y líneas rojas que tengas que sortear. En el desarrollo de software, una restricción por política puede estar relacionada con requisitos de seguridad o conformidad. Las políticas son la forma de limitadores más común en cualquier industria.
Supongamos un equipo que trabaja en un dominio regulado y se impone la política de que todo nuevo código tiene que ser aprobado por dos revisoras. No puedes eliminar la política, pero puedes tomar medidas alternativas. Así, por ejemplo, las desarrolladoras podrían trabajar en pareja, de modo que una de ellas puede actuar como revisora. La segunda persona revisora puede participar desde fuera en una revisión de código posterior junto con las autoras.
El hecho de que una persona de QA o una Manager tenga que aprobar todas las prestaciones que se incorporan al software suele generar un cuello de botella que parece difícil de sortear. En el primer caso, el tiempo de QA posiblemente estaría mejor empleado si este rol se ocupa de contribuir al desarrollo de la automatización de pruebas y a cualificar a las desarrolladoras para realizar más y mejores tests e introducir mejores prácticas. En el segundo caso, la Manager podría simplemente otorgar más confianza y responsabilidad al equipo y contribuir a definir mejor las expectativas y criterios de aceptación al principio del desarrollo. Y es que micro management y productividad suelen ir reñidas.
Otra política es la forma en que se han establecido los equipos de desarrollo. Ya hemos mencionado más arriba que los equipos por especialidad pueden suponer un cuello de botella a la hora de trabajar en proyectos que requieren capacitación multidisciplinar, ya que la distribución del trabajo es ineficiente y las necesidades de coordinación (en diseño, desarrollo y despliegue) consumen mucho tiempo. Por eso, las empresas orientadas a producto, suelen crear equipos multidisciplinares autónomos que puedan trabajar independientemente.
También es política la forma en que se distribuye el trabajo entre equipos, el tratamiento de los bugs, etc. A veces, el bloqueante puede ser incluso la ausencia de una política definida en un asunto, lo que nos obliga a improvisar cada vez que surge un problema.
Equipamiento: son los limitadores originados por equipo obsoleto, averiado o lento, o por falta de espacio. En desarrollo de software, puede ocurrir por tener ordenadores lentos o teclados estropeados. Puede ser por carecer de dispositivos o medios para realizar tests, etc. Podría ser incluso carecer un espacio silencioso para trabajar. Pero hay muchos más ejemplos.
Si los ordenadores de las desarrolladoras son lentos o de poca capacidad, la solución es bastante obvia. Para que eso no vuelva a ocurrir, se pueden definir unas especificaciones mínimas y actualizarlas regularmente. Otro ejemplo de limitador puede ser la obligación de utilizar un equipamiento estándar (teclados, ratones, monitores, etc.) en ver de permitir escoger estos elementos a cada persona, al menos dentro de un límite de presupuesto.
Las aplicaciones de trabajo cooperativo (Slack, Zoom, Miro, Meet, etc.) para las que algunas empresas no adquieren licencias completas o suficientes imponen limitaciones de uso que pueden afectar negativamente a la productividad. Si los precios son un problema, es posible implementar soluciones autogestionadas, en algunos casos, o tratar de estandarizarse en una plataforma más económica. Pero tampoco hay que desdeñar el coste de aprendizaje de utilizar herramientas que no son habituales en la industria.
Un limitante que me he encontrado a veces tiene que ver con los entornos de desarrollo locales. No todos los equipos trabajan en entornos de desarrollo normalizados usando tecnologías como Docker, para eliminar el síndrome de “en mi local funciona”. Cuando no disponemos de esta normalización, el comportamiento del software puede ser impredecible y, por tanto, nos obliga a usar mucho tiempo en descartar problemas debidos a las diferencias de entornos. Este y otros temas caben dentro de la llamada Developer experience y requiere el desarrollo de prácticas y herramientas que garanticen un comportamiento uniforme y predecible de los entornos locales.
Personas: los limitadores personales son cuellos de botella causados el número o capacitaciones de las personas participantes en el proyecto. Bien por no tener la capacitación adecuada, o incluso debido a lo contrario. Aquí también aplica la llamada Ley de Brooks, que dice que añadir personas a un proyecto lo ralentiza.
Demasiadas personas en un mismo proyecto pueden ser un limitador de la productividad. La Ley de Brooks se explica porque añadir manos a un equipo require un proceso de aprendizaje que en realidad retrasa al equipo hasta que las nuevas incorporaciones alcanzan el nivel de conocimiento requerido.
Por supuesto, demasiadas pocas personas para la carga de trabajo es otro limitador. Y en ese caso, la solución pasaría por reducir los lotes de trabajo a los que el equipo se compromete en cada iteración.
La multitarea de equipo también es un limitador de productividad. Si se trabaja a la vez en muchos proyectos poco relacionados, pueden pasar dos cosas:
Aquí, por ejemplo, se podría reorganizar el equipo para que varias personas trabajen juntas en el mismo proyecto, haciendo pair programming, facilitando así la distribución del conocimiento y reducción del bus factor. Pero también requeriría reducir la cantidad de proyectos simultáneos que un equipo puede gestionar.
Hay perfiles que pueden suponer un cuello de botella incluso siendo excepcionalmente competentes. Por ejemplo, una desarrolladora que emprende modificaciones significativas en el código sin contar con el resto del equipo. Esto puede introducir fricciones cuando mezcla los cambios y obliga a sus compañeras a empezar de cero con nuevos frameworks, librerías o conceptos, forzándolas a utilizar tiempo en este aprendizaje y no en sus propias tareas.
Paradigma: un limitador de paradigma es el causado por creencias. Por ejemplo, la creencia de que las líneas de código proporcionan una buena métrica por productividad, cuando lo opuesto suele ser cierto.
Una metodología mal implementada puede ser un gran limitador. Por ejemplo, muchos equipos de desarrollo que dicen hacer scrum, aplican las reglas del proceso de una manera sui generis, lo que acaba provocando problemas de productividad. El framework fue desarrollado con unos objetivos y si no cumplimos sus especificaciones tampoco podemos esperar resultados. Así que, si la metodología o paradigma del equipo se percibe como bloqueante, es necesario examinarlo y modificarla.
A medio camino entre metodología y personas estaría el tratamiento del trabajo no planificado. En un Scrum estricto, por ejemplo, el trabajo no planificado en forma de peticiones que llegan del otras partes de la organización durante un sprint no se debe realizar, sino que pasa a la lista de espera que es el backlog. Así que si entiendes el trabajo no planificado como un limitador, hay que tomar medidas para controlarlo.
Pero incluso sin seguir una metodología Scrum, el trabajo no planificado es algo que limita la productividad. Se hace necesario entonces definir qué hacer con él. A veces, es un tema de personas. Es frecuente que en el equipo exista alguna desarrolladora que, por las razones que sea, se siente obligada a responder a estas peticiones, lo que suele implicar que también existe una persona externa que sabe explotar esta característica. Así que puede ser necesario trabajar el aprender a decir no. Nadie dijo que mejorar la productividad de un equipo fuera fácil.
Mercado: un limitador de mercado ocurre al distribuir el software a los consumidores, si estás introduciendo más de lo requerido, porque estás invirtiendo tiempo en crear cosas que los consumidores no quieren o no necesitan y, por tanto, no utilizan.
Esto ocurre cuando se hacen desarrollos por si acaso o haciendo suposiciones que no están validadas por los expertos del dominio como hipótesis plausibles, o que no están apoyadas por datos, ya sean de uso, de análisis de la competencia, etc.
Es perfectamente válido hacer experimentos y lanzar al mercado prestaciones para ver qué acogida tienen. Pero lo correcto sería que estas prestaciones fuesen definidas a partir de una hipótesis de negocio que tenga sentido. Por ejemplo, puede que la competencia haya empezado a hacerlo y queremos comprobar que funciona en nuestro caso. O puede que sea algo que haya tenido éxito en otro mercado.
También podría haber un elemento no funcional. A lo mejor nos empeñamos en mejorar la performance de un sistema cuando no hay datos que lo justifiquen, e invertimos tiempo y recursos que se detraen del desarrollo de nuevas prestaciones que nuestras clientas sí necesitan.
Aquí tienes una selección de artículos que he usado como referencia o en los cuales pueden encontrar otros puntos de vista.
En cualquier caso, recomendaría mucho sus últimos posts sobre Git, en los que profundiza en todo tipo de aspectos de esta herramienta que usamos todos los días. Como muestra, el último que ha publicado sobre HEAD, o el anterior sobre opciones de configuración.
]]>This blog is about […] how being clear & curious & humble is better than sounding like I know it all already
Indently ofrece también cursos del lenguaje en Udemy y, a la vista de los vídeos, parecen ideales para pasar de simplemente competente en el lenguaje a dominarlo.
]]>Simple is better than complex_
Pues no. No estáis haciendo arquitectura hexagonal. Eso que mencionas es una arquitectura de tres capas que recoge conceptos comunes de las arquitecturas limpias:
¡Ojo!, no estoy diciendo que eso esté mal. De hecho, si lo estás haciendo así, en realidad está muy bien. Es solo que eso no se llama arquitectura hexagonal. Si hablas de arquitectura hexagonal háblame de puertos y háblame de adaptadores. Pero no de capas, aunque puedes organizar tu aplicación en capas si así te viene bien.
Y no es por ser pejiguero. Pero usar la terminología con precisión es un deber profesional. De otro modo, no podemos comunicarnos eficazmente.
Es raro el equipo de desarrollo que no diga que está haciendo Arquitectura Hexagonal en sus proyectos. Es raro el equipo de desarrollo que la esté haciendo realmente.
No, Domain-Application-Infrastructure no es arquitectura hexagonal
]]>Incluye herramientas como:
Y muchos más.
]]>This plugin is a powerful and versatile set of tools designed to enhance the development experience for software engineers. With its extensive collection of features, developers can increase their productivity and simplify complex operations without leaving their coding environment.
Escribir la librería de snapshot testing Golden en Go ha sido y está siendo una forma muy interesante de aprender no solamente sobre el lenguaje como tal, sino sobre técnicas del paradigma funcional que posiblemente no me hubiese planteado en otro contexto.
Pero portar la misma librería a PHP también está resultando muy instructivo. Estoy aprendiendo, y a veces re-aprendiendo, técnicas y características de PHP que me están sorprendiendo un poco. Una de las que me ha llamado la atención es utilizar el patrón de opciones funcionales que aprendí en Go, pero en PHP.
Las opciones funcionales son un patrón que nos ayuda a resolver el problema de pasar múltiples parámetros opcionales a un método o función. Es especialmente útil cuando queremos personalizar el comportamiento de un método o función desde el punto de vista de quien la llama, sobre todo cuando tiene un comportamiento por defecto.
En Golden es bastante fácil de ver que tenemos ese problema con Verify
. Por defecto, Verify
tiene un comportamiento definido que incluye:
__snapshots
..snap
.Sin embargo, nos interesa poder personalizar individualmente cada test en alguno de esos aspectos. Si tuviésemos que pasar un objeto de configuración para todas las posibles variantes de esos valores sería una tarea ímproba. Es preferible indicar qué aspecto concreto queremos modificar en el test y, para eso, lo mejor es pasar únicamente las opciones relevantes.
Por ejemplo, pasar una opción para guardar el snapshot en una carpeta distinta:
golden.Verify(t, output, golden.Folder("__examples"))
O bien una combinación de opciones: para guardar el snapshot en una carpeta distinta, con un nombre particular y sin hacer pasar el test.
golden.Verify(t, output, golden.Folder("__examples"), golden.Snapshot("my-snapshot"), golden.WaitApproval())
Estas opciones son, en realidad, funciones que devuelven otras funciones, que son las que actúan directamente sobre objetos dentro de Verify
. Específicamente, sobre el objeto que nos proporciona la configuración, como se puede ver en el fragmento a continuación:
func (g *Golden) Verify(t Failable, s any, options ...Option) {
g.Lock()
t.Helper()
conf := g.global
for _, option := range options {
option(&conf)
}
// Code removed for clarity
}
El parámetro options
nos proporciona un slice de Option
, que nos entrega una lista de funciones que reciben objetos Config
. Pero vamos a verlo mejor.
El tipo Option
es este: una función que puede recibir un puntero a un objeto Config
, de modo que puede mutarlo. En Golden, devuelve una función de tipo Option
para revertir el valor original si fuese el caso, pero como esto último no es estrictamente necesario, no voy a profundizar en ello.
type Option func(g *Config)
Para construir una opción funcional, lo que hacemos es crear una función que devuelva la función de tipo Option
que haga algo que nos interese. En este caso Snapshot
recibe un parámetro name
, con el que indicaremos el nombre del archivo de snapshot. Este parámetro lo usa una función anónima de tipo Option
que es la que devolvemos y se ejecutará en el bucle for
que vimos arriba. De este modo, el objeto conf
, será modificado en función de las opciones que hayamos pasado.
func Snapshot(name string) Option {
return func(c *Config) {
c.name = name
}
}
La función Snapshot
viene a actuar como si fuese una constructora de la función anónima que devuelve. De hecho, lo que recibe Verify
no es la función Snapshot
, sino la función anónima de tipo Option
, que es la verdadera opción funcional. Snapshot
nos permite añadir un poco de semántica y hacerlas reutilizables. Compara esto:
golden.Verify(t, output, golden.Snapshot("my-snapshot"))
Con lo que sería tener que crear la función desde cero. En principio esto tendría el mismo efecto… si se pudiese hacer:
snapshot := func(c *golden.Config) {
c.name = "my-snapshot"
}
golden.Verify(t, output, snapshot)
No se puede hacer porque estaríamos escribiendo la función en otro paquete y las propiedades de Config
serían privadas desde donde estamos. Config
tendría que tener setters con los que modificar sus propiedades. Aparte de eso, hay que tener en cuenta que nos estaría obligando a saber demasiado de la forma en que está implementada Verify
.
Y esto otro no funcionaría porque MySnapshot
ya no es de tipo Option
al introducir un parámetro extra en la firma.
func MySnapshot(c *goldenConfig, name string) {
c.name = name
}
En resumen: esta fórmula nos permite proporcionar un mecanismo para actuar sobre la configuración sin exponer sus detalles. Cada opción funcional sabe cómo tiene que modificar Config
.
func Snapshot(name string) Option {
return func(c *Config) {
c.name = name
}
}
Si a todo lo anterior le añadimos la deconstrucción options ...Options
:
func (g *Golden) Verify(t Failable, s any, options ...Option) {
g.Lock()
t.Helper()
conf := g.global
for _, option := range options {
option(&conf)
}
// Code removed for clarity
}
Lo que conseguimos es exponer la posibilidad de pasar un número indeterminado de opciones, o ninguna, para personalizar el comportamiento de la función o método invocados.
PHP no tiene un enfoque tan funcional como Go, pero resulta que tiene lo suficiente como para aplicar el mismo patrón. Creo que salvo la posibilidad de definir tipos que son funciones, todo lo demás es básicamente igual, con las lógicas salvedades:
$this->verify($output, Folder("__folder"));
Aquí tenemos el ejemplo con la opción funcional Folder
. Se devuelve una función que actúa sobre un objeto Config
el cual, en este caso, expone un setter
llamado setPrefix
. Como ocurría en la versión de Go, la función Folder
actúa como una constructora de la opción funcional, cuyo objetivo es cambiar el valor de una propiedad en Config
.
function Folder(string $prefix): \Closure
{
return fn(Config $config) => $config->setPrefix($prefix);
}
El código de verify
en PHP es bastante parecido al de Go. Se obtienen las opciones, que son callable
, aunque posíblemente podríamos restringirlo a Closures
, y se ejecuta cada una de ellas, lo que da como resultado que la configuración ha sido sobreescrita para este test en particular.
public function verify($subject, callable ...$options): void
{
$this->init();
$config = $this->config
foreach ($options as $option) {
$option($config);
}
// Code removed for clarity
}
El patrón functional options no es exclusivo de Go, sino más genéricamente del paradigma funcional. Cualquier lenguaje que tenga funciones de primer orden puede aplicarlo. Esto es: si el lenguaje te permite pasar funciones como parámetros y devolverlas como retorno, entonces el patrón es aplicable.
Usar este patrón es muy interesante dado que permite exponer mecanismo con los que personalizar el comportamiento de una función, pero sin exponer sus interioridades. Además, valiéndonos de signaturas variádicas, podemos añadir fácilmente todo un vocabulario de opciones para ello.
]]>Esto que aquí ves es un test que testea un test. Parece una tontería, pero para mí ha sido todo un descubrimiento.
final class VerifyTest extends TestCase
{
#[Test]
/** @test */
public function shouldNotPass(): void
{
$storage = new MemoryStorage();
$testCase = new class("Example test") extends TestCase {
use Golden;
};
$testCase->registerStorage($storage);
$testCase->verify("This is the subject");
$this->assertTrue($storage->exists("Example test"));
$this->expectException(ExpectationFailedException::class);
$testCase->Verify("This is another output");
}
}
Retrocedamos un poco.
Hace poquito publiqué una librería de Snapshot testing para Go. En parte porque quería aprender sobre el tema y también porque en el trabajo nos viene bien. La que estábamos usando (Approvals) nos genera un poco de fricción con algunas cosas y no teníamos muchas opciones para solventarlo.
No es que escribir una librería para esto tenga una gran dificultad intrínseca, pero me resultó interesante como experiencia y me obligó a aprender algunos temas de Go que nunca había tocado.
Finalmente, hemos empezado a usarla recientemente, hasta el punto de que ya hemos migrado todos los tests que lo necesitaban. Lo que más nos ha convencido ha sido la predictibilidad de los snapshots, por un lado, y que expone una interfaz fácil de usar, pero con un montón de control de todos los detalles necesarios.
Y en vista del éxito, comencé a plantearme escribir una versión para PHP.
La librería Golden para Go está escrita basándose en muchas características y convenciones propias de Go. Y pensando en hacer la versión PHP, me parece importante hacerlo de una forma que resulte natural para PHP.
Así, por ejemplo, PHP es un lenguaje con una orientación a objetos más clásica que la de Go y esto nos permite hacer cosas de manera diferente. Ni mejor, ni peor: diferente.
Así, mi primer spike fue probar con funciones. Por ejemplo, una función Verify
. El problema que tiene crear una librería de testing como Golden es que tiene que colaborar e integrarse con otras como PHPUnit. De esta forma, contribuye al recuento de tests pasados y fallados.
La forma más sencilla que se me ha ocurrido para ello es pasar el propio TestCase que se está ejecutando:
function Verify(TestCase $testCase, $subject): void
{
// Code to generate snapshot if needed
}
Esto nos permite hacer aserciones usando las facilidades del TestCase, de tal manera que los resultados se incorporan a las estadísticas y el control de la ejecución del test sigue en manos de PHPUnit.
function Verify(TestCase $testCase, $subject): void
{
// Code to generate snapshot if needed
$this->assertEquals($snapshot, $subject);
}
Una cosa que tenía clara que quería hacer era abstraer la creación de los snapshots. Esto es, en lugar de cuajar el código de Verify con toda la creación y mantenimiento de archivos de snapshot, quería tenerlo separado en un objeto responsable. Pero, ¿cómo acceder a ese objeto dentro de la función? Pues, ejem…, haciéndolo global.
global $storage;
$storage = new Storage();
function Verify(TestCase $test, $subject): void
{
global $storage;
if (!$storage->exists($test->name())) {
$storage->keep($test->name(), $subject);
}
$snapshot = $storage->retrieve($test->name());
TestCase::assertEquals($snapshot, $subject);
}
La alternativa sería pasarlo en cada invocación, pero hace que la firma de la función empiece a arrastrar muchas cosas y se hace inconveniente.
function Verify(TestCase $test, Storage $storage, $subject): void
{
if (!$storage->exists($test->name())) {
$storage->keep($test->name(), $subject);
}
$snapshot = $storage->retrieve($test->name());
TestCase::assertEquals($snapshot, $subject);
}
El testing también tiene sus complicaciones. Vamos por partes:
final class VerifyTest extends TestCase
{
#[Test]
/** @test */
public function shouldPass(): void
{
Verify($this, "This is the subject");
try {
Verify($this, "This is another output");
} catch (ExpectationFailedException $exception) {
return;
}
$this->fail("Verify should fail");
}
}
El uso de la función es muy similar al de la original en Go:
Verify($this, "This is the subject");
Aquí la tienes para comparar.
golden.Verify(t, "This is the subject")
En Go, pasar t
es una especie de estándar, ya que t
nos da acceso a una serie de métodos útiles para testing. Por ejemplo, en Golden se usa para saber el nombre del test en ejecución, para decirle a Go que Verify
es un helper
(una función que colabora en el testing) y para provocar el fallo del test (cuando el snapshot y el subject no coinciden).
Pero en PHP pasar el TestCase ($this) no se siente natural. No es incorrecto y puede tener su sentido, pero en este caso resulta extraño. Sería más natural algo como $this->Verify("This is the subject")
. Pero ya volveremos a eso.
La forma en que está escrito el test es un poco rara. El primer uso de Verify intenta probar que el test pasa cuando se verifica por primera vez un output y se genera el snapshot, ya que asumimos que ese output es correcto.
final class VerifyTest extends TestCase
{
#[Test]
/** @test */
public function shouldPass(): void
{
Verify($this, "This is the subject");
try {
Verify($this, "This is another output");
} catch (ExpectationFailedException $exception) {
return;
}
$this->fail("Verify should fail");
}
}
Pero no hay nada que lo demuestre. Faltaría añadir una línea que compruebe el outcome, verificando que se ha creado el snapshot en $storage
.
final class VerifyTest extends TestCase
{
#[Test]
/** @test */
public function shouldPass(): void
{
Verify($this, "This is the subject");
$this->assertTrue($storage->exists("Example test"));
try {
Verify($this, "This is another output");
} catch (ExpectationFailedException $exception) {
return;
}
$this->fail("Verify should fail");
}
}
Realmente, este test ahora debería partirse en dos:
final class VerifyTest extends TestCase
{
#[Test]
/** @test */
public function shouldPass(): void
{
Verify($this, "This is the subject");
$this->assertTrue($storage->exists("Example test"));
}
#[Test]
/** @test */
public function shouldNotPass(): void
{
Verify($this, "This is the subject");
try {
Verify($this, "This is another output");
} catch (ExpectationFailedException $exception) {
return;
}
$this->fail("Verify should fail");
}
}
El segundo test (shouldNotPass
) simula que hacemos una segunda ejecución del test y el output ha cambiado, por lo que Verify
debería fallar.
Pero esto produce una situación contradictoria: para que este test pase, el propio test tiene que fallar. Así que nos vemos en la obligación de introducir esta segunda llamada e Verify en un try/catch y esperar que se lance la excepción ExpectationFailedException
, lo que indicaría el comportamiento deseado. Y si eso no se produce, forzar el fallo del test.
Es un test muy alambicado y extrañamente realizado. Todo funciona como es debido y me permite construir mi Storage
, pero no me convence.
Y en este punto decidí que el enfoque no era el adecuado.
Los Traits no son santo de mi devoción. Pero como toda herramienta pueden tener sus usos. Y en este caso, me parece que funciona bastante mejor para mis propósitos.
Los Traits permiten una especie de herencia transversal, añadiendo rasgos de comportamiento a una clase. Mi opinión personal es que están bien para añadir soporte a comportamiento técnico a una clase sin contaminar su significado dentro de un dominio. Por ejemplo, yo añado el soporte a eventos en objetos de dominio mediante un Trait, de modo que no tengo que hacer descender todas las Entidades o Agregados de una clase abstracta.
Pero estoy divagando. En este caso lo que me planteo es: ¿qué tal sería usar las funciones de la librería Golden como Traits que se puedan añadir un TestCase?
En lo que respecta a la API permite un lenguaje mucho más natural, sin tener que añadir parámetros, lo que deja espacio para las opciones que tendremos que admitir en el futuro:
$this->verify("This is the subject")
Y puesto que el Trait
tiene acceso al TestCase
a través de $this
se cumple el requisito de colaborar con PHPUnit.
En lo que se refiere a testing, me encuentro también con muchas ventajas. La principal es que se separa el test de la librería de la simulación de hacer un test con la librería:
final class VerifyTest extends TestCase
{
protected Storage $storage;
protected function setUp(): void
{
$this->storage = new MemoryStorage();
}
#[Test]
/** @test */
public function shouldPass(): void
{
$testCase = new class("Example test") extends TestCase {
use Golden;
};
$testCase->registerStorage($this->storage);
$testCase->verify("This is the subject");
$this->assertTrue($this->storage->exists("Example test"));
}
}
Esto es algo que se aprecia especialmente en el test del caso en el que el output actual es distinto del snapshot. Ahora el test comprueba que $this->verify
falla, que es lo que queremos que pase:
final class VerifyTest extends TestCase
{
#[Test]
/** @test */
public function shouldNotPass(): void
{
$testCase = new class("Example test") extends TestCase {
use Golden;
};
$testCase->registerStorage($this->storage);
$testCase->verify("This is the subject");
$this->expectException(ExpectationFailedException::class);
$testCase->verify("This is another output");
}
}
El recurso al Trait también tiene sus problemas. ¿Cómo iniciamos el Storage? Los Traits pueden tener función constructora, pero interfiere con la constructora de la clase en la que se usan. Y en este caso, no es algo que nos convenga.
Una cosa que no he mencionado es que, en condiciones normales, Storage no es más que una capa de abstracción sobre las acciones de escribir y leer archivos. Pero en el desarrollo de PHPGolden quiero poder no usar el sistema de archivos, sino una implementación en memoria que sea fácil de inspeccionar y que tenga poco mantenimiento.
Así que necesito poder:
Esta es mi primera solución, siendo Storage
una interface. De momento, solo estoy usando la implementación MemoryStorage
, pero en el futuro init
instanciará la implementación basada en el sistema de archivos.
trait Golden
{
private Storage $storage;
private function init(): void
{
$this->registerStorage(new MemoryStorage());
}
public function registerStorage(Storage $storage): void
{
$this->storage = $storage;
}
public function verify($subject): void
{
if (!isset($this->storage)) {
$this->init();
}
if (!$this->storage->exists($this->name())) {
$this->storage->keep($this->name(), $subject);
}
$snapshot = $this->storage->retrieve($this->name());
$this->assertEquals($snapshot, $subject);
}
}
Seguramente la versión final de PHPGolden usará la implementación basada en Trait
pero me ha parecido interesante reflejar el proceso de investigación.
Portar una librería de un lenguaje a otro es algo más que traducir código. Implica reflexionar sobre la forma más natural y cómoda en cada lenguaje. Lo que funciona bien en uno, no es necesariamente aplicable al otro.
]]>Literalmente: en la parte I explica los Tydings: pequeños refactors que puedes aplicar nada más leer cada uno de sus breves capítulos.
La parte II se dedica a cuestiones de organización. Cómo gestionar esta limpieza de código para que no se convierta en un viaje sin retorno.
La parte III explica por qué funciona esta aproximación al diseño de software.
Hay que decir que nosotros también tenemos un libro bastante decente sobre el tema, que por cierto estamos actualizando.
Software design is an exercise in human relationships.
Tidy First? A Personal Exercise in Empirical Software Design
]]>En parte, como ejercicio de aprendizaje. En parte, porque no me acaban de encajar otras librerías disponibles.
Snapshot testing es una técnica bastante usada en desarrollo frontend que consiste en guardar el output de nuestro código y usarlo como criterio para ejecutar futuros tests. De este modo, creamos un test de regresión que nos asegure que mantenemos el comportamiento actual de una unidad de software.
En backend, el snapshot testing no es tan usado, pero hay muchos casos de uso para esta técnica: objetos complejos, generación de archivos de todo tipo (JSON, CSV, XML, etc.) para los que es costoso desarrollar un test basado en aserciones.
Además, esta técnica es muy potente usada con código legacy o, en general, con código que no tiene tests. Nos permite obtener una buena cobertura rápidamente, antes de intervenir en un código.
Golden, además de snapshot testing, nos permite trabajar con approval testing. En esta modalidad, lo que hacemos es mantener el test fallando a propósito hasta que el snapshot que se ha generado sea revisado por nosotras o por una experta del dominio que nos pueda decir si el output es correcto o no. Cuando nos satisface, “aprobamos” el snapshot y lo usamos como criterio en los tests futuros.
Approval testing es una técnica adecuada cuando estamos escribiendo código nuevo que genera objetos complejos o documentos.
Finalmente, Golden ofrece la posibilidad de realizar los tests combinatorios de la técnica Golden Master. Esta técnica consiste en bombardear el código a base de llamadas con distintas combinaciones de sus parámetros, de tal modo que lo forcemos a recorrer todos sus posibles flujos de ejecución.
Para ello, no tenemos más que indicarle a Golden listas de valores para cada parámetro de entrada de la unidad bajo test y generará todas las combinaciones posibles. Esta técnica puede ayudarnos a obtener una cobertura completa de un código existente sin tener que preocuparnos de entenderlo en profundidad. Una vez que hemos generado el “golden master” y estamos protegidas por el test, podemos empezar a aplicar técnicas de refactor para mejorar su diseño.
]]>]]>Software development is a learning process; working code is a side effect