Testando componentes React assíncronos

Quando decidimos escrever testes para nossos componentes no frontend, é uma boa prática, e até uma forma de facilitar nossa escrita de testes, separar o que é lógica do que é apresentação. Dessa forma, podemos ter testes separados para cada uma das responsabilidades.

Porém, em alguns casos, um componente não é apenas responsável pela apresentação dos dados, mas também pela busca desses dados.

A partir da versão 13 do Next.js, com o uso do diretório app, todos os componentes criados são server components, o que significa que a renderização ocorre do lado do servidor. Isso abre várias possibilidades, como por exemplo, fazer chamadas de API assíncronas na definição do componente, aguardar a resposta e utilizar os dados dessa resposta na renderização do componente.

Como exemplo, podemos definir nosso componente como uma função assíncrona:

async function MyAsyncComponent() { 
    const data = await fetchData(); 
    return <div data-testid="painted-content">{data}</div>; 
}

Em um componente marcado como async, podemos utilizar o await para aguardar. Não precisamos, nem devemos, utilizar hooks como useState ou useEffect.

Escrevendo o nosso teste da seguinte forma, com a sintaxe familiar da React Testing Library, ele falhará:

describe('Test My Async Component', () => {
    it('should render the component', () => {
        render(<MyAsyncComponent />)
        const sut = screen.getByTestId('painted-content')
        expect(sut.textContent).toBe('async content')
    })
})

O erro retornado é algo como Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead., ou seja, o componente não está retornando um elemento JSX para ser renderizado.

Como resolver esse problema?

Partindo do princípio de que um componente nada mais é do que uma função assíncrona que recebe parâmetros para renderizar seu conteúdo, podemos escrever nosso teste da seguinte forma:

describe('Test My Async Component', () => {
    it('should render the component', async () => {
        const jsx = await MyAsyncComponent()
        render(jsx)
        const sut = screen.getByTestId('painted-content')
        expect(sut.textContent).toBe('async content')
    })
})

O que fizemos no exemplo acima foi transformar nosso teste anterior em um teste assíncrono, aguardando a função do componente resolver, armazenando seu JSX em uma constante, e passando essa constante com JSX para o método render. Ao inspecionar o tipo do retorno do nosso componente, temos o tipo JSX.Element, exatamente o mesmo tipo de dado que a função render aceita. Dessa forma, o teste agora roda e passa com sucesso.

Refatorando

É bem provável que você vá renderizar o componente em diversos casos de testes. Para isso, podemos abstrair para uma função própria:

async function renderComponent() {
    const jsx = await MyAsyncComponent()
    return render(jsx)
}

describe('Test My Async Component', () => {
    it('should render the component', async () => {
        await renderComponent()
        const sut = screen.getByTestId('painted-content')
        expect(sut.textContent).toBe('async content')
    })
})

Dessa forma, evitamos reescrever em todos os casos de teste a chamada assíncrona do nosso componente e a renderização do JSX. Porém, podemos ir além. Em uma aplicação real, é comum termos diversos componentes assíncronos que precisam ser testados. Inclusive, alguns deles, diferente dos nossos exemplos até aqui, podem receber props. Para isso, podemos utilizar os genéricos do TypeScript para facilitar ainda mais a renderização:

async function renderAsync<T>(
    asyncComponent: (props: T) => Promise<JSX.Element>,
    props: T = {} as T
) {
    const jsx = await asyncComponent(props);
    render(jsx);
}

Utilização:

describe('Test My Async Component', () => {
    it('should render the component without params', async () => {
        await renderAsync(MyAsyncComponent)
        const sut = screen.getByTestId('painted-content')
        expect(sut.textContent).toBe('async content')
    })
})

describe('Test My Async Component with props', () => {
    it('should render the component with params', async () => {
        const props = { params: 'with my example param' }
        await renderAsync(MyAsyncComponentWithProps, props)
        const sut = screen.getByTestId('painted-content')
        expect(sut.textContent).toBe('async content with my example param')
    })
})

Conclusão

Ao escrever testes para componentes assíncronos no frontend, é essencial compreender a diferença entre componentes de apresentação e componentes que também lidam com a lógica de busca de dados. Com a introdução dos server components no Next.js 13, novas oportunidades surgem para otimizar a renderização no servidor e simplificar o manuseio de dados assíncronos.

Ao transformar nossos testes em assíncronos e refatorar a lógica de renderização, conseguimos garantir que nossos componentes sejam testados de forma eficaz e eficiente. Além disso, utilizando genéricos do TypeScript, podemos criar funções de renderização reutilizáveis que tornam nossos testes mais limpos e mantêm nosso código mais organizado.

Com essas práticas, podemos melhorar a confiabilidade e a mantenibilidade dos testes em nossos projetos de frontend, garantindo que nossos componentes funcionem conforme esperado em diferentes cenários.


Referências


publicado também em: dev.to