¡Hola Frontender@! ✨ Si has oído hablar antes del AB Testing o incluso si lo has puesto ya en práctica, sabrás que se trata de una metodología para determinar si tu flamante nueva idea de producto gusta o no a tus usuarios, averiguar cómo impacta en las métricas de tu negocio y, en definitiva, si te conviene conservarla o no.
Introducción
Trabajo como 👨🏻💻 Desarrollador Frontend en Adevinta Spain, donde cualquier cambio que llega a producción acaba rápidamente en manos de millones de usuarios. Bajo estas condiciones, subir un desarrollo sin medir su impacto podría ser un desastre, así que esta técnica resulta imprescindible.
Para hacer AB Testing, necesitas una plataforma que cubra la gestión de los datos. Para eso existen varias opciones, nosotros usamos Optimizely. Todas ofrecen cosas similares y no vamos a entrar en eso, pues el foco del artículo es la parte en React.
Dicho esto, hablemos de ⚛️ React. Me gustaría compartir contigo la experiencia que hemos vivido desde la perspectiva Frontend, dificultades que hemos afrontado y, como consecuencia, cómo hemos iterado nuestra primera solución hasta llegar a la que utilizamos hoy día.
La primera solución
Vamos a poner un ejemplo sencillo. Imagina que quieres medir el impacto de cambiar el texto de un botón porque tienes la hipótesis de que, con ese otro texto, el botón puede ser más atractivo para el usuario.
En Optimizely configurarías algo como lo siguiente y obtendrías unos IDs.
Experimento | ID | Tráfico |
---|---|---|
Mejorar botón | 123 | 100% |
Variantes | ID | Tráfico |
---|---|---|
Variante A | 1116 | 50% |
Variante B | 1117 | 50% |
Nuestro primer enfoque fue diseñar un componente al que le pasabas el render de cada variante como un hijo, y te renderizaba automáticamente el que correspondía a la variante asignada al usuario.
<Experiment experimentId={123}>
<button variationId={1116} defaultVariation>Comprar</button>
<button variationId={1117}>¡Compra ya!</button>
</Experiment>
La variante original tiene una prop adicional llamada defaultVariation
que la identifica como la que se ha de mostrar por defecto.
Por lo demás, el código es bastante declarativo y resulta en lo siguiente.
Render | |
---|---|
Si caigo en variante A | Comprar |
Si caigo en variante B | ¡Compra ya! |
Esto está muy bien y funciona, pero conforme fuimos haciendo experimentos más ambiciosos y variados, el uso invitó a una reflexión sobre algunas limitaciones de esta aproximación que tienen que ver con la experiencia de desarrollo.
⚠️ Limitación #1 – Probar variantes en local
La limitación más tonta es que, para probar las variantes en local, no quedaba más remedio que ir moviendo la prop defaultVariation
de una variante a otra.
<Experiment experimentId={123}>
<button variationId={1116}>Comprar</button>
<button variationId={1117} defaultVariation>¡Compra ya!</button>
</Experiment>
Los problemas de esto:
- Esa prop no fue diseñada para hacer eso.
- Puedes commitearla por error en una posición equivocada.
- Por motivos que explicaré luego, no estás emulando lo que realmente pasa en la activación real de una variación, con lo que estás comprobando tu desarrollo con un comportamiento distinto al que se dará en producción.
⚠️ Limitación #2 – Zonas distantes en mismo render
La segunda limitación entra cuando quieres afectar a zonas distantes dentro del mismo render, porque la única manera razonable de hacerlo es metiendo el componente allí donde haga falta, con la estructura de IDs y variantes repetida.
<div className="product-detail">
<Experiment experimentId={123}>
<button variationId={1116} defaultVariation>Comprar</button>
<button variationId={1117}>¡Compra ya!</button>
</Experiment>
...
...
...
<Experiment experimentId={123}>
<button variationId={1116} defaultVariation>Favorito</button>
<button variationId={1117}>¡A favoritos!</button>
</Experiment>
</div>
Problema de esto: estoy duplicando información.
El problema se agrava bastante cuando tengo variantes que participan en diferentes componentes y repositorios para el mismo experimento.
⚠️ Limitación #3 – Desde componente padre a hijos
La tercera limitación entra en juego cuando quieres afectar a los hijos desde el componente padre, porque lo que haces entonces es pasar props, y son props que su única motivación es la existencia del experimento.
<Experiment>
...
<ParentVariation />
↳ <DescendantA isExperiment /> 😱
↳ <DescendantB isExperiment /> 😱
↳ <DescendantC isExperiment /> 😱
↳ <DescendantD isExperiment /> 😱
↳ <DescendantE isExperiment /> 😱
↳ <DescendantF isExperiment /> 😱
↳ ...
</Experiment>
Problemas de pasar props:
- Puede ser costoso, sobretodo cuando hay muchos niveles en la jerarquía.
- Los componentes se llenan de props que no forman parte de su contrato.
- Luego, cuando decidas quedarte con una variante, se hace muy difícil quitar los restos del experimento, has de ir recogiendo todas esas migas.
⚠️ Limitación #4 – Fuera de la zona de render
Finalmente, la última limitación aparece cuando te das cuenta de que quieres hacer cosas fuera del render para cuando se carga determinada variante.
const Actions = () => {
// ❌👇 Aquí no puedo saber en qué variante estoy
const someData = getSomeData(/* ... */)
const handleClick = () => { /* ... */ }
return (
<Experiment experimentId={123}>
<button variationId={1116} defaultVariation>Comprar</button>
<button variationId={1117}>¡Compra ya!</button>
</Experiment>
)
}
Yo no puedo llegar ahí con un componente. ¿Qué es lo que sí puedo hacer? Bueno, si tu componente es pequeño como este, es verdad que puedes subir el experimento al componente padre para que te lleguen props.
Por otro lado, si tu componente es grande y complejo el refactor se te puede complicar.
Análisis de Experiencia de Desarrollo
Problemas
- ❌ La lentitud y fallos producto de probar las variantes en local.
- ❌ La persecución de la información duplicada, esparcida por los lugares más inhóspitos.
- ❌ El cambio de contrato no deseado en mis componentes.
Soluciones
- ✅ Definir una API concreta para probar las variantes en local.
- ✅ Reducir la fuente de la verdad para cada experimento.
- ✅ Proveer maneras de ampliar el alcance sin generar ruido, es decir, que esa fuente de la verdad llegue más lejos con las mínimas afectaciones posibles en mi infraestructura.
La iteración
Queremos que nuestras herramientas nos ayuden y sabemos que una misma solución no funciona para siempre, porque las cosas cambian. Por eso, tras el análisis anterior, empezó un proceso de mejora de las herramientas.
🆕 Props para probar variantes
Se añaden nuevas props que pueden usarse en el componente del experimento: forceVariation
y forceActivation
. Ambas props aceptan los mismos valores: el ID de la variante que quieres forzar o una letra del abecedario que corresponda al orden en que están presentadas las variantes.
Por ejemplo, si le enchufo una “B” se va a estar refiriendo a la segunda variante, y así no tengo que poner el ID completo que suele ser bastante largo.
<Experiment experimentId={123} forceActivation="B">
<button variationId={1116} defaultVariation>Comprar</button>
<button variationId={1117}>¡Compra ya!</button>
</Experiment>
La diferencia entre forceVariation
y forceActivation
es que forceVariation
va a obligar a la variante especificada a comportarse como si fuera la variante por defecto, mostrándose en el primer render.
En cambio, forceActivation
mantendrá la variante por defecto en el primer render, y simulará una activación como la que hace Optimizely, haciendo un segundo render con la variante especificada. Esto permite detectar problemas que antes no podíamos ver hasta configurar el experimento completo en Optimizely.
En general, se reduce la dificultad de probar variantes en local, y si se colaran en una revisión de código por error, que sería muy difícil, no pasaría nada porque están diseñadas a propósito para que en producción se ignoren, por si las moscas.
🆕 Contexto para experimentos
Se implementa un contexto exclusivo para todos los experimentos, en el que viene un objeto con toda la información sobre el estado del experimento, incluyendo unos booleanos muy chulos para saber en qué variante estamos.
<Experiment> 🚀
...
<ParentVariation />
↳ <DescendantA />
↳ <DescendantB />
↳ <DescendantC />
↳ <DescendantD />
↳ <DescendantE />
↳ <DescendantF /> ← useExperiment() 😍
↳ ...
</Experiment>
Este contexto se provee automáticamente a través del componente de React y se puede consumir mediante el nuevo hook useExperiment
en cualquier punto descendiente de la jerarquía.
De esta manera, se empieza a ampliar el alcance de un experimento evitando ruido en mis componentes. Ya no necesitamos aquel interminable taladro de props, porque ahora la información relevante viaja sin intermediarios desde la fuente de la verdad hasta allí donde se invoque.
🆕 Hook como origen de experimento
La zona prohibida fuera del render deja de ser prohibida, porque el hook gana la capacidad de actuar como origen y gestor del estado del experimento si le pasas su configuración, algo que antes solo podía hacer el componente, y devuelve la misma información que se recibía al consumir el contexto, con los booleanos para saber en qué variante estamos.
const Actions = () => {
// 1️⃣👇 Creamos el experimento con el hook...
const {isVariationB} = useExperiment({
experimentId: 123,
variations: [{id: 1116, isDefault: true}, 1117]
})
// 2️⃣👇 Y ya puedo saber aquí en qué variante estoy ✅
const someData = getSomeData(/* ... */)
const handleClick = () => { /* ... */ }
return (
<button>{isVariationB ? '¡Compra ya!' : 'Comprar'}</button>
)
}
Además, si queremos seguir propagando el contexto hacia abajo para tener ocasión de consumirlo, por definición los hooks no pueden hacerlo, pero podemos envolver el render con el componente Experiment y obligarlo a actuar solamente de proveedor pasándole solo la prop feed con lo que devuelve el hook de useExperiment. De esta manera actuará exclusivamente de proveedor de contexto, y podremos consumir en niveles inferiores la información del experimento.
Gracias a esta última iteración, ningún experimento está limitado al área del render, llevando las herramientas de AB Testing a un grado de alcance bastante potente.
Conclusiones
A día de hoy estamos muy contentos con estas mejoras y realmente nos ayudan a ser mucho más ágiles haciendo AB Tests. Pero las tratadas en este artículo no son las únicas, ¡más adelante hablaremos de otros retos afrontados!
También, es importante destacar que todos estos cambios vinieron de forma progresiva para que la adopción fuera asequible y, más importante, totalmente retrocompatible con la solución anterior.
¡Eso es todo! Estas herramientas son opensource y están documentadas y testeadas. Te invito a que les eches un vistazo y quedamos siempre abiertos a cualquier aportación. 🙌🏻