DEV Community

Cover image for AB Testing en el Frontend con React
Ismael Ramon for Adevinta Spain

Posted on • Updated on

AB Testing en el Frontend con React

¡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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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. 🙌🏻

Alt Text

Discussion (5)

Collapse
midudev profile image
Miguel Ángel Durán 👨‍💻

¡Muy buen artículo Isma! 👏 Super interesante.

Collapse
arnaurius profile image
Arnau Rius

Super Top 🔥🙌👏

Collapse
metgauss profile image
Javier Roldán • Edited on

Muy buen artículo!! Congrats!!

Collapse
joanclaret profile image
Joan Claret

¡Super artículo! ¡felicidades!

Collapse
andykaiser profile image
Andy Kaiser

Muy interesante! ¿Habeis hecho algún test para detectar el efecto del flickering en los experimentos (cuando se carga async)? ¿O cargais el script de Optimizely como blocking?