Cómo reutilizar tests con React Testing Library usando composición

Es habitual ver que en el código de test no seguimos las mismas buenas prácticas que seguimos en el código de producción.

Una práctica que se ve muchas veces, es tener muchos test repetidos. La acción de copiar y pegar un fichero de test existente, cambiarle el fixture o algún detalle más es bastante frecuente.

El problema surge cuando en esos test hay que modificar algo porque el comportamiento cambia, o queremos usar otros métodos de la api del framework de test por el motivo que sea y tenemos que hacer el mismo cambio en 10 o 15 ficheros de test.

En este artículo vamos a ver un caso muy concreto de test en una aplicación React que normalmente podría conllevar código duplicado y vamos a ver como utilizando composición evitamos el código duplicado de una forma bastante sencilla.

Nuestro escenario

Nuestro escenario consiste en un escenario muy típico en desarrollo web donde tenemos una página con una tabla para representar una lista de elementos.

table-web

Test duplicados

Existe código de creación de todos las tablas que es igual y se repite a lo largo de todas las páginas. Lo lógico es evitar tener código duplicado en las páginas utilizando table builders por ejemplo, pero esta parte no toca en este artículo.

Vamos a centrarnos en los test, donde también vamos a tener código duplicado y vamos a ver la forma de evitar esto en React y en concreto usando Jest y React Testing Library pero eso es lo de menos, da igual el la libraría de tests que uses.

Y este sería un ejemplo de test de una página:

const endpoint = `countries`;
const apiEndpoint = `/api/v1/${endpoint}`;

describe(`countries list page`, () => {
    beforeEach(() => givenAValidAuthenticatedUser());

    it("should does not render any rows", async () => {
        givenADataList(0);

        render(<CountryListPage />);

        const rows = await screen.findAllByRole("row");

        expect(rows.length).toBe(1);
    });

    it("should render expected rows", async () => {
        const items = givenADataList(13);

        render(<CountryListPage />);

        const pageRows = await screen.findAllByRole("row");

        pageRows.shift();

        expect(pageRows.length).toBe(items.length);
    });

    it("should render expected error message ", async () => {
        givenAErrorServerResponse();

        render(<CountryListPage />);

        await screen.findByText(
            "Sorry, an error has ocurred in the server. Please try later again"
        );
    });
    it("should render expected row after search", async () => {
        const items = givenADataList(15);

        render(<CountryListPage />);

        const searchTerm = items[12]["name"] as unknown as string;

        await searchAndVerifyAsync(searchTerm);
    });
});

function givenADataList(count: number): CountryData[] {
    const items = Array.from(Array(count).keys()).map((_, index) => {
        const code = ("0" + index).slice(-2);

        return {
            id: Id.generateId().value,
            name: `name ${code}`,
            iso2: code,
        };
    });

    mockServerTest.addRequestHandlers([
        {
            method: "get",
            endpoint: apiEndpoint,
            httpStatusCode: 200,
            response: items,
        },
    ]);

    return items;
}

function givenAErrorServerResponse(
    method: Method = "get",
    httpStatusCode = 500,
    endpoint = apiEndpoint
) {
    mockServerTest.addRequestHandlers([
        {
            method,
            endpoint,
            httpStatusCode: httpStatusCode,
            response: {
                statusCode: httpStatusCode,
                error: "error",
                message: "error message",
            },
        },
    ]);
}

Como podemos ver estos mismos test vamos a tener que realizarlos entre las diferentes páginas que consisten en una tabla de nuestra aplicación, ya que no son específicos para una en concreto, sino que son tests que debemos realizar para todas aquellas páginas que consisten en una tabla.

Es cierto que nos van a ser idénticos pero si hay bastante parte común que vamos a llevarnos a otro sitio y utilizaremos mediante composición.

Composición

¿Por que utilizar comosición para evitar código duplicado en los tests en Typescript?. Porque en Typescript las funciones son cuidadano de primer orden, los test están basados en funciones y no en clases como en otros lenguajes como java o c#.

Un test de React Testing Library, que usa Jest, sigue esta estructura:

describe(`test group`, () => {
    it("test name", async () => {
    });
});

Como se puede apreciar no hay clases por ningun lado, son solo funciones.

Lo que podemos hacer es creanos una función común que ejecuta los test que son comunes y que reciba por parámetro los datos que necesita para ejecutar los tests.

Vamos a ver como sería la estructura de los tests de forma sencilla antes de visualizarlo con nuestro ejemplo más complejo.

Por un lado tenemos los test comunes en un fichero separado

//common_test.spec.ts
export const executeCommonTest = (text: string) => {
    describe(`test group`, () => {
        test("Test 1", () => {
            expect(text === "").toBe(false);
        });
        test("Test 2", () => {
            expect(text.includes("_")).toBe(false);
        });
    });
};

// just to avoid warning, that no tests in test file
describe("Common tests", () => {
    test("test name", () => {});
});

Este fichero es necesario que tenga un test aunque sea vacío para evitar un warning al ejecutar los test.

Y posteriormente tenemos nuestro fichero de test normal donde invocamos a los test comunes.

//normal_test.spec.ts
commonListPageTests("example_");

Refactorizando los test

Vamos a ver como aplicamos esto a nuestro ejemplo. Por un lado tendríamos los test comunes, donde recebiríamos por parámetro los datos necesarios.

Necesitamos pasar como parámetro una función para crear los datos a representar en la tabla, el endpoint de la api, los campos a verificar de la tabla y el componente a renderizar.

La función debe ser genérica ya que cada página de tabla a probar trabajará con una entidad diferente.

//commonListPageTests.spec.ts

interface DataListCreator<TData> {
    givenADataList: (count: number) => TData[];
}

export const commonListPageTests = <TData>(
    endpoint: string,
    verifiableFields: (keyof TData)[],
    dataCreator: DataListCreator<TData>,
    component: React.ReactElement
) => {
    const endpointToTest = `/api/v1/${endpoint}`;

    beforeEach(() => givenAValidAuthenticatedUser());

    describe(`${endpoint} list page`, () => {
        it("should does not render any rows", async () => {
            givenADataList(0);

            const { container } = render(component);

            await tl.verifyTableIsEmptyAsync(container);
        });
        it("should render expected rows ", async () => {
            const items = givenADataList(13);

            const { container } = render(component);

            await tl.verifyTableRowsAsync(container, items, verifiableFields);
        });
        it("should render expected error message ", async () => {
            givenAErrorServerResponse();

            render(component);

            await tl.verifyTextExistsAsync(
                "Sorry, an error has ocurred in the server. Please try later again"
            );
        });
        it("should render expected row after search", async () => {
            const items = givenADataList(15);

            const { container } = render(component);

            const searchTerm = items[12][verifiableFields[0]] as unknown as string;

            await tl.searchAndVerifyAsync(container, searchTerm);
        });
    });

    function givenADataList(count: number): TData[] {
        const items = dataCreator.givenADataList(count);

        mockServerTest.addRequestHandlers([
            {
                method: "get",
                endpoint: endpointToTest,
                httpStatusCode: 200,
                response: items,
            },
        ]);

        return items;
    }

    function givenAErrorServerResponse(
        method: Method = "get",
        httpStatusCode = 500,
        endpoint = endpointToTest
    ) {
        mockServerTest.addRequestHandlers([
            {
                method,
                endpoint,
                httpStatusCode: httpStatusCode,
                response: {
                    statusCode: httpStatusCode,
                    error: "error",
                    message: "error message",
                },
            },
        ]);
    }
};

// just to avoid warning, that no tests in test file
describe("Common tests for CRUD routes", () => {
    test("should be used per implementation", () => {});
});

Posteriormente tendríamos los test de cada una de las páginas de tabla ejecutando a los test comunes, vamos a ver como sería uno de ellos.

//CountryListPage.spec.tsx

const verifiableFields: (keyof CountryData)[] = ["id", "name"];

const dataListCreator = {
    givenADataList: (count: number): CountryData[] => {
        const dataList = Array.from(Array(count).keys()).map((_, index) => {
            const code = ("0" + index).slice(-2);

            return {
                id: Id.generateId().value,
                name: `name ${code}`,
                iso2: code,
            };
        });

        return dataList;
    },
};

commonListPageTests("countries", verifiableFields, dataListCreator, <CountryListPage />);

En este fichero podríamos tener otros tests específicos a esta página si fuera el caso, y que no aplica tenerlo dentro de los test comunes.

Conclusiones

Hemos visto en este artículo como a través de la composición, en el caso de React con jest, podemos reutilizar tests que prueban una misma lógica para cada tipo de páginas similares.