Arneses para testeo de componentes de UI

No paro de encontrarme este caso últimamente. Considera un componente contenedor con pestañas, que se pueda definir de la siguiente forma:

<TabsContainer>

    <Tab id="tab-1" title="Primera pestaña">
        <h1>Estás en la primera pestaña</h1>
        <p>Este es el contenido de la primera pestaña.</p>
    </Tab>

    <Tab id="tab-2" title="Segunda pestaña">
        <h1>Estás en la segunda pestaña</h1>
        <p>Este es el contenido de la segunda pestaña.</p>
    </Tab>

</TabsContainer>

Aquí el resultado:

Estás en la primera pestaña

Este es el contenido de la primera pestaña.

Son dos tipos de componente, TabsContainer y Tab, si bien el segundo solo tiene sentido dentro del primero.

De entrada, este tipo de componentes presenta un desafío de comunicación. Por ejemplo, la cabecera (la línea de pestañas como tal) es renderizada en el propio contenedor principal como un <nav> con elementos <button> en este caso. Cabe preguntarse cómo sabe el padre exactamente qué hijos lo componen.

No estamos indicando por propiedades o atributos cuáles son las pestañas, sino que establecemos su contenido como hijos directos del contenedor.

No es trivial en absoluto que el contenedor “sepa” qué hijos tiene, como sí podría ocurrir en un entorno vanilla con DOM puro (filtrando con children aquellos que tienen alguna clase).

Existen múltiples formas de resolver este problema de comunicación, y una de las más típicas y efectivas es usar un contexto compartido expuesto por el padre hacia los hijos.

En bibliotecas como React o frameworks como Svelte contamos con contextos. En Vue tenemos provide/inject, donde provide genera un contexto e inject lo expone en el descendiente que queramos.

En este caso (con Svelte), podemos hacer que cuando un componente Tab es montado, este acceda a un conjunto de funciones expuestas por contexto (una API) del padre (TabsContext), entre ellas una función registerTab.

En una parte de TabsContainer (padre):

// TabsContainer.svelte
// ... 
    export type TabID = string
    export type TabTitle = string
    export interface TabsContext {
    	currentTabID: () => TabID | undefined,
    	registerTab: (tabID: TabID, title: TabTitle) => void
    }
    
    let currentTabID  = $state<TabID | undefined>()
    let tabsInfo: { [key: TabID]: TabTitle } = $state({})

    function registerTab(tabID: TabID, title: TabTitle) {
    	tabsInfo[tabID] = title
    	const isNoTabActive = currentTabID === undefined
    	if (isNoTabActive) { currentTabID = tabID }
    }
    
    setContext<TabsContext>('tabs', {
    	currentTabID: () => currentTabID,
    	registerTab
    })
// ...

En una parte de Tab (hijo):

// Tab.svelte
// ...
interface TabsProps {
    readonly id: TabID,
    title: TabTitle,
    children: Snippet
}

let { id, title, children }: TabsProps = $props()
const tabsContext = getContext<TabsContext>('tabs')
// svelte-ignore state_referenced_locally
tabsContext.registerTab(id, title)
// ...
Diagrama de comunicación entre TabsContainer y Tab mediante contexto

La complejidad de testar componentes compuestos

Cuando se requiere el renderizado compuesto de múltiples componentes, muchas bibliotecas tienen ciertas dificultades por las dependencias de mecanismos más avanzados o modernos.

A veces simplemente no funcionan. Especialmente cuando dependemos de mecanismos de bibliotecas de terceros, nuevos o de renderizado “programático”. En este caso la context API de Svelte puede simplemente fallar con los mecanismos tradicionales: no está preparada para una creación programática de componentes hijos (a fecha de publicación de esta entrada), sino que espera un entorno de ejecución algo más “real” donde desde un componente se construyan todos.

Esto nos lleva a la estrategia de utilizar “arneses”. Observa el siguiente componente:

<!-- TabsTestHarness.svelte -->
<script lang="ts">
import TabsContainer from '$lib/ui/TabsContainer.svelte'
import Tab from '$lib/ui/Tab.svelte'

interface TabConfig {
    id: string
    title: string
    content: string
}

let { tabs = [] }: { tabs: TabConfig[] } = $props()
</script>


<TabsContainer>
{#each tabs as tab (tab.id)}
    <Tab id={tab.id} title={tab.title}>
        {tab.content}
    </Tab>
{/each}
</TabsContainer>

Es un arnés (harness), el componente que usaremos en los tests para montar el escenario completo:

// Parte de TabsContainer.svelte.spec.ts ...
it('shows first tab content by default', async () => {
    render(TabsTestHarness, { // Se usa el arnés de testeo
        props: {
            tabs: [ // Se le pasan datos para que construya las <Tab>
                { id: 'tab1', title: 'First Tab', content: 'Content 1' },
                { id: 'tab2', title: 'Second Tab', content: 'Content 2' }
            ]
        }
    })

    await expect.element(page.getByText('Content 1')).toBeInTheDocument()
    await expect.element(page.getByText('Content 2')).not.toBeInTheDocument()
})
// ...
    

¿Contras?

En testing siempre se aconseja evitar lógicas complejas en la construcción de los escenarios de pruebas. Es preferible siempre un determinismo brutal y que se pueda leer de entrada qué se pretende. Explicitar cada caso cuanto sea posible.

Esta preferencia choca con la idea de usar arneses, que introducen complejidad y un nivel de abstracción en la construcción del escenario de testeo. Aún más si son parametrizables como es el caso.

Sin embargo, las alternativas que me he encontrado hasta ahora en distintas auditorías tomaban un camino que a veces conllevaba rediseñar el componente (por ejemplo, para que solo trabajara con propiedades de forma explícita, sin admitir contenido arbitrario como hijos) o hacer sobreingeniería hasta conseguir que funcionara una característica de un framework o biblioteca en un flujo natural.

Lo peor es que ni siquiera se obtenía finalmente una mayor legibilidad del test. Pero aún así, lo que se pudiera ganar en legibilidad y comprensión del test se perdía en la implementación en sí misma. Adaptar el diseño para mejorar la testabilidad es algo deseable, pero recordemos que el principal objetivo es la sostenibilidad, no hacer tests bonitos por tener tests bonitos.