DEV Community

Cover image for Testando componentes React
Gustavo Santos
Gustavo Santos

Posted on

Testando componentes React

Disclaimer

Cover photo by Ben White on Unsplash


Nesse artigo vamos desenvolver um sistema de login (somente o front-end) com suporte a multi idiomas e testar esse sistema de login usando Jest e Testing Library. Porém esse artigo vai além do básico de testes de componentes, vamos aprender a usar tabelas de dados de testes, vamos fazer mocks do backend usando o Mock Service Worker e iremos usar um pouquinho de testes de propriedade.

Espero que esse artigo seja útil para você. Tentei condensar as principais ferramentas e técnicas que uso no dia a dia para garantir interfaces estáveis e prontas para serem refatoradas a qualquer momento.

O projeto

Não vamos criar um projeto do zero, existem vários artigos por aí que fazem muito bem esse trabalho. Porém vamos começar de um projeto base que deixei preparado nesse repositório. Então faça o clone e instale as dependências.

Você deve ter notado que existe um arquivo pnpm-lock.yaml no repositório. No projeto base eu usei o PNPM, que aconselho que pelo menos você dê uma olhada no projeto. Não vou gastar palavras explicando o PNPM nesse artigo porque a ferramenta merece um artigo próprio. Mas fique livre para deletar o arquivo e instalar as dependências usando NPM ou Yarn.

O projeto base contém 3 arquivos principais, o Login.jsx, seu respectivo arquivo de testes Login.spec.js e um arquivo App.jsx que ainda não é usado.

Adicionando o formulário de login

Formulários são coisas bem complicadas de gerenciar, para evitar complexidade desnecessária, vamos usar a biblioteca React Hook Form para nos ajudar com os formulários.

Vamos instalar o React Hook Form:

$ pnpm install react-hook-form
Enter fullscreen mode Exit fullscreen mode

Para evitar de acoplar o formulário de login na página de login, vamos criar um diretório src/components/forms que vai agrupar todos os formulários da aplicação. Vamos então criar um componente chamado LoginForm dentro do diretório recém criado e implementar um formulário de login simples:

// src/components/forms/LoginForm.jsx

import React from 'react'
import { useTranslation } from 'react-i18next'
import { useForm } from 'react-hook-form'

export default function LoginForm ({ onSubmit }) {
  const { t } = useTranslation()
  const { register, handleSubmit } = useForm()

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="email">{t('email')}</label>
      <input name="email" type="email" ref={register} />

      <label htmlFor="password">{t('password')}</label>
      <input name="password" type="password" ref={register} />

      <button type="submit">{t('login_action')}</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Legal, mas agora precisamos adicionar os testes para esse formulário. Vamos criar um arquivo LoginForm.spec.jsx logo ao lado do arquivo com o código fonte do formulário de login com um teste simples para garantir que o nosso componente está renderizando normalmente.

// src/components/forms/LoginForm.spec.jsx

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import LoginForm from './LoginForm'
import i18n from '../../config/i18n'

describe('LoginForm', () => {
  let t

  beforeAll(async () => {
    t = await i18n
  })

  test('Should render the component', async () => {
    const handleSubmit = jest.fn()
    render(<LoginForm onSubmit={handleSubmit} />)
    await waitFor(() =>
      expect(screen.getByText(t('login_action'))).toBeInTheDocument()
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

Agora no terminal, vamos executar o Jest:

$ pnpm run test

> jest --no-cache

 PASS  src/components/Login.spec.jsx
 PASS  src/components/forms/LoginForm.spec.jsx

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.501 s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

So far, so good. Mas o nosso formulário de login de fato funciona? Testar se um componente renderiza pode ser útil quando o componente deve ser montado de acordo com algumas condições via props. Porém o nosso caso não é esse. O formulário de login deve ser montado sempre, portanto não faz sentido testar se o componente montou. Mas vamos manter esse teste para fins didáticos.

Antes de escrever qualquer teste um pouco mais avançado, vamos primeiro instalar mais uma dependência para facilitar a nossa vida:

$ pnpm install --save-dev @testing-library/user-event
Enter fullscreen mode Exit fullscreen mode

O @testing-library/user-event contém uma série de utilitários muito úteis! Vale a pena checar o repositório depois de terminar de ler esse artigo: https://github.com/testing-library/user-event.

Pronto, vamos adicionar um novo teste para garantir que, preenchendo os dados do formulário e clicando no botão de login, o callback onSubmit deve ser chamado com os dados corretos.

// src/components/forms/LoginForm.spec.jsx

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
import i18n from '../../config/i18n'

describe('LoginForm', () => {
  // ...

  test('Should call the onSubmit callback when confirm', async () => {
    const handleSubmit = jest.fn()
    render(<LoginForm onSubmit={handleSubmit} />)

    userEvent.type(screen.getByLabelText(t('email')), 'user@email.com')
    userEvent.type(screen.getByLabelText(t('password')), '1234567')

    userEvent.click(screen.getByText(t('login_action')))

    await waitFor(() => expect(handleSubmit).toBeCalledTimes(1))
    expect(handleSubmit).toBeCalledWith({
      email: 'user@email.com',
      password: '1234567'
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

Importante: note no código acima que eu omiti parte do código que já existia no arquivo LoginForm.spec.jsx. Isso será feito ao longo do texto para evitar de causar ruído desnecessário no texto.

Se você rodar pnpm run test novamente no terminal, virá que temos 3 testes passando:

$ pnpm run test

> jest --no-cache

 PASS  src/components/Login.spec.jsx
 PASS  src/components/forms/LoginForm.spec.jsx

Test Suites: 2 passed, 2 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        3.751 s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

Talvez você esteja se perguntando: o que deve acontecer quando a pessoa clicar no botão de Login sem ter preenchido o E-mail ou a senha? Realmente, há um problema de usabilidade no componente de login. O botão de login só deve disparar o callback onSubmit se o usuário preencher o E-mail e a senha.

Vamos primeiro criar um teste para este comportamento:

// src/components/forms/LoginForm.spec.jsx

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
import i18n from '../../config/i18n'

describe('LoginForm', () => {
  // ...

  test('Should call the onSubmit callback only when the email and password is filled', async () => {
    const handleSubmit = jest.fn()
    render(<LoginForm onSubmit={handleSubmit} />)

    userEvent.click(screen.getByText(t('login_action')))
    await waitFor(() => expect(handleSubmit).not.toBeCalled())

    userEvent.type(screen.getByLabelText(t('email')), 'abc@email.com')
    userEvent.click(screen.getByText(t('login_action')))
    await waitFor(() =>
      expect(screen.getByText(t('password_required'))).toBeInTheDocument()
    )
    expect(handleSubmit).not.toBeCalled()

    // clean up
    userEvent.clear(screen.getByLabelText(t('email')))

    userEvent.type(screen.getByLabelText(t('email')), 'abc@email.com')
    userEvent.type(screen.getByLabelText(t('password')), 'some_password')
    userEvent.click(screen.getByText(t('login_action')))

    await waitFor(() => expect(screen.queryAllByRole('alert')).toHaveLength(0))

    expect(handleSubmit).toBeCalledTimes(1)
    expect(handleSubmit).toBeCalledWith({
      email: 'abc@email.com',
      password: 'some_password'
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

Aqui vemos que os testes falham:

    expect(jest.fn()).not.toBeCalled()

    Expected number of calls: 0
    Received number of calls: 1

    1: {"email": "", "password": ""}

      52 |     await userEvent.click(screen.getByText(t('login_action')))
      53 | 
    > 54 |     expect(handleSubmit).not.toBeCalled()
         |                              ^
      55 | 
      56 |     await userEvent.type(screen.getByLabelText(t('email')), 'abc')
      57 |     await userEvent.type(screen.getByLabelText(t('password')), '1234567')
Enter fullscreen mode Exit fullscreen mode

Vamos ajustar nosso formulário para evitar que o callback onSubmit seja chamado caso o usuário não tenha preenchido E-mail ou senha. Em outras palavras, vamos marcar que tanto E-mail quanto a senha são campos obrigatórios.

Felizmente escolhemos usar o React Hook Form como dependência para nos ajudar com formulários. A função register aceita, dentre outras coisas, um parâmetro que indica que o campo é obrigatório. Vamos alterar o formulário de login:

// src/components/forms/LoginForm.jsx

export default function LoginForm({ onSubmit }) {
  const { t } = useTranslation()
  const { register, handleSubmit, errors } = useForm()

  const submit = ({ email, password }) => onSubmit({ email, password })

  return (
    <form onSubmit={handleSubmit(submit)}>
      <label htmlFor="login_email">{t('email')}</label>
      <input
        id="login_email"
        name="email"
        type="email"
        ref={register({ required: true })}
      />
      {errors.email && (
        <span className="form-error" role="alert">
          {t('email_required')}
        </span>
      )}

      <label htmlFor="login_password">{t('password')}</label>
      <input
        id="login_password"
        name="password"
        type="password"
        ref={register({
          required: true
        })}
      />
      {errors.password && (
        <span className="form-error" role="alert">
          {t('password_required')}
        </span>
      )}

      <button type="submit" data-testid="login_submit">
        {t('login_action')}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Agora todos os nossos testes passam.

Atenção! A função userEvent.type retorna uma Promise. Não espere pela Promise ser resolvida. Isso causa uma falha de sincronia com a biblioteca Testing Library.

Integrando com o backend

O funcionamento do formulário de login já está bem coberto por testes, porém nosso trabalho ainda não acabou. Precisamos integrar com uma API rest.

A responsabilidade de integrar com a API é do componente Login. Aqui fica claro o motivo de desacoplar o formulário de login do componente de login. Assim podemos compor ambos os componentes.

Sabemos que a API rest responde no endpoint /auth/login. Precisamos fazer um POST para este endpoint passando as credenciais do usuário no corpo da requisição. Vamos criar um serviço para lidar com essa questão.

Nosso serviço de autenticação vai usar o axios por baixo dos panos. Então vamos instalar o axios no nosso projeto:

$ pnpm install axios
Enter fullscreen mode Exit fullscreen mode

Agora vamos criar o diretório src/services, que vai conter os serviços da aplicação. Dentro do diretório src/services vamos criar um arquivo chamado AuthService.js:

// src/services/AuthService.js

import axios from 'axios'

const AuthService = {
  routes: {
    login: '/auth/login'
  },

  login({ email, password }) {
    return axios.post(this.routes.login, { email, password })
  }
}

export default AuthService
Enter fullscreen mode Exit fullscreen mode

O AuthService é um objeto que contém as rotas que o serviço de autenticação precisa, bem como os métodos que interagem com a API rest.

O método login recebe um objeto que contém email e password como propriedades, executa uma requisição POST no endpoint de login e retorna o resultado.

Agora vamos alterar o componente de login para usar o novo serviço de login:

// src/components/Login.jsx

import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginForm from './forms/LoginForm'
import AuthService from '../services/AuthService'

export default function Login() {
  const { t } = useTranslation()
  const [logged, setLogged] = useState(false)
  const [loginError, setLoginError] = useState(undefined)

  const handleSubmit = async ({ email, password }) => {
    try {
      await AuthService.login({ email, password })
      setLogged(true)
    } catch (e) {
      setLoginError(t('user_not_found'))
    }
  }

  return (
    <div>
      <h1>{t('login')}</h1>

      {!logged && <LoginForm onSubmit={handleSubmit} />}
      {logged && <div>{t('login_success')}</div>}
      {!!loginError && <span role="alert">{loginError}</span>}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Algumas coisas mudaram no componente Login. Primeiro, caso a autenticação do usuário perante o servidor ocorra com sucesso, o formulário de login será substituído por um texto de sucesso. Caso contrário, um aviso de erro de autenticação será mostrado.

Ok, e agora? Não temos uma API rest rodando (digamos que o time de backend ainda não terminou de implementar o endpoint de login). Para devidamente testar o login, vamos precisar fazer um mock do login. Mas antes de fazer qualquer tipo de mock usando jest.spyOn ou mockImplementation, vamos pensar se não há alguma forma um pouco mais esperta de resolver o problema.

Para garantir que estamos testando o comportamento dos componentes React, precisamos nos preocupar com o mínimo possível de características de implementação. Fazer um mock de uma função é como se fosse olhar com um óculos de raio-x para dentro do código do componente. É importante lembrar que o nosso componente deve ser tratado como uma caixa preta.

Um usuário não deve precisar saber o que uma função retorna, se essa função é assíncrona ou não, se é pura ou impura.

Felizmente existe uma ferramenta incrível chamada Mock Service Worker. O objetivo do MSW é iniciar um servidor simples que atua como uma API rest (ou GraphQL). Vamos adicionar o MSW no nosso projeto como uma dependência de desenvolvimento:

$ pnpm install --save-dev msw
Enter fullscreen mode Exit fullscreen mode

Agora vamos criar o diretório src/mocks para escrever as definições desse servidor. Dentro do diretório src/mocks vamos definir os handlers e exemplos de respostas da API rest.

Os exemplos de respostas da API rest vou definir dentro do diretório chamado fixtures. Um exemplo pode deixar as coisas mais claras.

Vamos criar um arquivo que representa a resposta da API rest caso o login aconteça com sucesso:

// src/mocks/fixtures/login-success.json

{
  "token": "the token"
}
Enter fullscreen mode Exit fullscreen mode

Ou seja, se o login ocorreu com sucesso, um token JWT será retornado no corpo da requisição.

Vamos criar também um arquivo que representa a resposta da API rest caso aconteça alguma falha na autenticação:

// src/mocks/fixtures/login-error.json

{
  "message": "User not found"
}
Enter fullscreen mode Exit fullscreen mode

A API retorna uma mensagem avisando que o usuário não foi encontrado. A mensagem não é útil para nós, já que o sistema de login aceita multi idiomas. Por causa disso existem mensagens customizadas de falha de login nos arquivos de traduções (veja o diretório src/locales).

Agora vamos criar uma função que lida com o login. O formato dessa função é bem parecido com os handlers do express. No diretório src/mocks/handlers, crie um arquivo chamado login-handler.js com o seguinte conteúdo:

// src/mocks/handlers/login-handler.js

import { rest } from 'msw'
import AuthService from '../../services/AuthService'

import responseSuccess from '../fixtures/login-success.json'
import responseError from '../fixtures/login-error.json'
import user from '../fixtures/stored-user.json'

const createLoginHandler = () =>
  rest.post(AuthService.routes.login, (req, res, ctx) => {
    if (req.body.email === user.email && req.body.password === user.password) {
      return res(ctx.status(200), ctx.json(responseSuccess))
    } else {
      return res(ctx.status(403), ctx.json(responseError))
    }
  })

export default createLoginHandler
Enter fullscreen mode Exit fullscreen mode

O login handler usa um arquivo que define um usuário para representar um usuário que existe no banco de dados. O conteúdo desse arquivo é:

// src/mocks/fixtures/stored-user.json

{
  "name": "Gustavo",
  "email": "gustavofsantos@outlook.com",
  "password": "123456"
}
Enter fullscreen mode Exit fullscreen mode

A ideia do login handler é simples. Se as credenciais passadas no corpo da requisição POST forem as mesmas armazenadas no arquivo que define um usuário, então o login ocorre com sucesso. Caso contrário, um erro de acesso negado é retornado.

Agora vamos alterar um pouco o arquivo de testes do componente de login para lidar com o fluxo de autenticação:

// src/components/Login.spec.jsx

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Login from './Login'
import i18n from '../config/i18n'
import user from '../mocks/fixtures/stored-user.json'

describe('Login', () => {
  let t

  beforeAll(async () => {
    t = await i18n
  })

  test('Should render the component', async () => {
    render(<Login />)
    await waitFor(() =>
      expect(screen.getByText(t('login'))).toBeInTheDocument()
    )
  })

  test('Should log in the user', async () => {
    render(<Login />)

    userEvent.type(screen.getByLabelText(t('email')), user.email)
    userEvent.type(screen.getByLabelText(t('password')), user.password)
    userEvent.click(screen.getByText(t('login_action')))

    await waitFor(() =>
      expect(screen.getByText(t('login_success'))).toBeInTheDocument()
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

E voilà, todos os testes passam:

$ pnpm run test

> jest --no-cache

 PASS  src/components/Login.spec.jsx
 PASS  src/components/forms/LoginForm.spec.jsx

Test Suites: 2 passed, 2 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        4.012 s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

Agora precisamos cobrir o caso onde o usuário não está cadastrado no banco de dados.

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Login from './Login'
import i18n from '../config/i18n'
import user from '../mocks/fixtures/stored-user.json'

describe('Login', () => {
  // ...

  test('Should not log in the user that is not registered', async () => {
    render(<Login />)

    userEvent.type(screen.getByLabelText(t('email')), user.email)
    userEvent.type(screen.getByLabelText(t('password')), 'some other password')
    userEvent.click(screen.getByText(t('login_action')))

    await waitFor(() =>
      expect(screen.queryAllByText(t('user_not_found'))).toHaveLength(1)
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

E novamente, todos os nossos testes passam:

$ pnpm run test

> jest --no-cache

 PASS  src/components/forms/LoginForm.spec.jsx
 PASS  src/components/Login.spec.jsx

Test Suites: 2 passed, 2 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        4.155 s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

Não sei se você concorda comigo mas é um saco descrever todos os casos de teste. Além disso, quando humanos executam uma tarefa repetitiva muitas vezes, há grandes chances da pessoa cometer algum erro ou esquecer algum caso. Especialmente os casos de borda (edge cases).

Talvez um modelo mais eficiente de testes seja mais interessante.

Usando tabelas de teste

Vamos começar refatorando um pouco o nosso arquivo com os testes do formulário de login.

// src/components/forms/LoginForm.spec.jsx

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
import i18n from '../../config/i18n'

describe('LoginForm', () => {
  // ...

  test.each([
    ['a@b.c', '1234567', { email: 'a@b.c', password: '1234567' }],
    ['aaaaa@b.c', '', undefined]
  ])(
    'Should call the onSubmit callback only when the email and password are valid',
    async (email, password, expected) => {
      const handleSubmit = jest.fn()
      render(<LoginForm onSubmit={handleSubmit} />)

      userEvent.type(screen.getByLabelText(t('email')), email)
      userEvent.type(screen.getByLabelText(t('password')), password)
      userEvent.click(screen.getByText(t('login_action')))

      if (!!email && !!password) {
        await waitFor(() => expect(handleSubmit).toBeCalled())
        expect(handleSubmit).toBeCalledWith(expected)
      } else {
        await waitFor(() => expect(handleSubmit).not.toBeCalled())
      }
    }
  )
})
Enter fullscreen mode Exit fullscreen mode

Foi adicionado um novo teste que usa uma tabela de testes. A função test.each aceita uma lista de listas de argumentos para a função que de fato executa os testes. Ou seja

test.each([
  [arg0, arg1, arg2, ...],
  [arg0, arg1, arg2, ...],
  [arg0, arg1, arg2, ...],
])('The test description with %s interpolation', (arg0, arg1, ...) => {
  // test body
})
Enter fullscreen mode Exit fullscreen mode

Os parâmetros da tabela de testes serão mapeados diretamente na função que executa o teste. Além disso, é um padrão que, caso exista o caso de comparação, este caso será o último no array de argumentos.

Na verdade, com esse novo teste podemos deletar todos os testes que já havíamos escrito no arquivo LoginForm.spec.jsx. O arquivo com os testes do formulário de login terá a seguinte cara:

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
import i18n from '../../config/i18n'

describe('LoginForm', () => {
  let t

  beforeAll(async () => {
    t = await i18n
  })

  test.each([
    ['a@b.c', '1234567', { email: 'a@b.c', password: '1234567' }],
    ['aaaaa@b.c', '', undefined]
  ])(
    'Should call the onSubmit callback only when the email and password are valid',
    async (email, password, expected) => {
      const handleSubmit = jest.fn()
      render(<LoginForm onSubmit={handleSubmit} />)

      userEvent.type(screen.getByLabelText(t('email')), email)
      userEvent.type(screen.getByLabelText(t('password')), password)
      userEvent.click(screen.getByText(t('login_action')))

      if (!!email && !!password) {
        await waitFor(() => expect(handleSubmit).toBeCalled())
        expect(handleSubmit).toBeCalledWith(expected)
      } else {
        await waitFor(() => expect(handleSubmit).not.toBeCalled())
      }
    }
  )
})
Enter fullscreen mode Exit fullscreen mode

Mais compacto, não acha? Será que podemos fazer melhor?

O modelo de login

Vamos começar criando um pequeno e simples modelo de login. O modelo deve implementar o funcionamento correto do login, porém da forma mais simples possível. O modelo de login não precisa ser performático, precisa implementar da forma correta o funcionamento do formulário de login.

Vamos começar implementando esse modelo no arquivo LoginFormModel.js:

// src/components/forms/LoginFormModel.js

const LoginFormModel = {
  login(email, password) {
    if (
      typeof email === 'string' &&
      email.length > 3 &&
      typeof password === 'string' &&
      password.length >= 6
    ) {
      return true
    }

    return false
  }
}

export default LoginFormModel
Enter fullscreen mode Exit fullscreen mode

O modelo de formulário de login é simples. Se o email e senha cumprem com as regras de login -- email cujo tamanho é maior que 3 caracteres e senha maior que 6 caracteres; então o login é um sucesso e o modelo retorna true. Caso contrário o modelo retorna false. Note que não há problemas quanto a senhas com espaços. O sistema proposto aceita qualquer tipo de caracter como parte da senha.

Agora vamos adicionar mais uma dependência no nosso projeto:

$ pnpm install --save-dev fast-check
Enter fullscreen mode Exit fullscreen mode

Agora vamos criar mais um teste no nosso arquivo de testes do formulário de login. O formato desse teste é um pouco diferente, mas garanto que logo logo tudo fará sentido:

// src/components/forms/LoginForm.spec.jsx

import React from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as fc from 'fast-check'
import LoginForm from './LoginForm'
import i18n from '../../config/i18n'
import LoginFormModel from './LoginFormModel'

describe('LoginForm', () => {
  // ...

  test(
    'Should call the onSubmit callback when email and password are valid',
    () =>
      fc.assert(
        fc
          .asyncProperty(
            fc.scheduler({ act }),
            fc.emailAddress(),
            fc.string(),
            async (s, email, password) => {
              const handleSubmit = jest.fn()
              const { getByLabelText, getByText } = render(
                <LoginForm onSubmit={handleSubmit} />
              )

              s.scheduleSequence([
                () => userEvent.type(getByLabelText(t('email')), email),
                () => userEvent.type(getByLabelText(t('password')), password),
                async () => userEvent.click(getByText(t('login_action')))
              ])

              await s.waitAll()

              if (LoginFormModel.login(email, password)) {
                expect(handleSubmit).toBeCalledWith({
                  email,
                  password
                })
              } else {
                expect(handleSubmit).not.toBeCalled()
              }
            }
          )
          .beforeEach(async () => {
            await cleanup()
          })
      ),
    15000
  )
})
Enter fullscreen mode Exit fullscreen mode

Wow, muitas coisas acontecendo aqui. O pacote fast-check implementa várias primitivas para testes de propriedade. Não é o objetivo desse artigo ir a fundo em testes de propriedade. O universo de testes de propriedades é muito grande e merece um artigo a parte. Aqui vamos nos concentrar em um espectro bem reduzido de testes de propriedade que são testes contra um modelo.

O objetivo do teste é checar se um código, seja ele uma função, variável ou objeto contém todas as propriedades relacionadas a um determinado modelo. O exemplo mais simples é uma função de adição. A soma de dois números deve ser a mesma independente da ordem dos operandos. Essa é a propriedade associativa da adição.

A mesma ideia é empregada no código anterior, porém checamos se o componente LoginForm implementa as propriedades do modelo LoginFormModel. O modelo de formulário de login só tem uma "propriedade", que é o login. O login é verdadeiro caso email e senha estejam dentro das regras de login.

Note que foi preciso definir um timeout para o teste. Os testes gerados pelo fast-check causam um aumento considerável do tempo que um teste leva para ser executado. Como a asserção das propriedades do formulário de login é uma função assíncrona, caso o teste demore mais do que o timeout padrão do Jest a execução é interrompida. Evitamos esse tipo de comportamento aumentando o timeout.

Note também que a primeira propriedade mapeada no teste é um agendador. O fast-check irá agendar as ações sobre o formulário de login automaticamente para nós, porém precisamos definir a sequência de ações, isso é feito no trecho abaixo:

s.scheduleSequence([
  () => userEvent.type(getByLabelText(t('email')), email),
  () => userEvent.type(getByLabelText(t('password')), password),
  async () => userEvent.click(getByText(t('login_action')))
])
Enter fullscreen mode Exit fullscreen mode

Outro modo de entender o funcionamento do agendador é encarar como o momento da atuação. O fast-check vai agendar e executar uma sequência de atuações assíncronas, cada atuação (função assíncrona) será executada após a anterior terminar. Esse comportamento vai garantir a ordem das chamadas e vai evitar vazamentos de memória.

Voltando ao comportamento do modelo de formulário de login, se o método login retorna verdadeiro, o callback onSubmit deve ser chamado. Caso contrário, o callback não deve ser chamado. Essa é a propriedade do formulário de login.

Vamos rodar nossos testes, crentes de que o nosso componente já está muito bem testado:

$ pnpm run test

  ● LoginForm › Should call the onSubmit callback when email and password are valid

    Property failed after 1 tests
    { seed: -1640604784, path: "0:0:0:0:0:0:0:0:0:1:0:0", endOnFailure: true }
    Counterexample: [schedulerFor()`
    -> [task${1}] sequence resolved
    -> [task${2}] sequence resolved
    -> [task${3}] sequence resolved`,"a@a.ap"," "]
    Shrunk 11 time(s)
    Got error: Error: expect(jest.fn()).not.toBeCalled()
Enter fullscreen mode Exit fullscreen mode

Oops, parece que houve um erro. O fast-check consegue gerar inúmeras combinações de E-mail e senhas. Porém após a primeira combinação, foi encontrada uma combinação de E-mail e senha onde o nosso componente viola o modelo de login -- o contra exemplo onde o E-mail é "a@a.ap" e a senha " ". Isso indica que o nosso formulário de login precisa implementar as validações do modelo.

No código fonte do formulário, vamos aumentar os requisitos dos inputs de E-mail e senha:

// src/components/forms/LoginForm.jsx

export default function LoginForm({ onSubmit }) {
  const { t } = useTranslation()
  const { register, handleSubmit, errors } = useForm()

  const submit = ({ email, password }) => onSubmit({ email, password })

  return (
    <form onSubmit={handleSubmit(submit)}>
      <label htmlFor="login_email">{t('email')}</label>
      <input
        id="login_email"
        name="email"
        type="email"
        ref={register({
          required: true,
          validate: (value) => value.length > 3
        })}
      />
      {errors.email && (
        <span className="form-error" role="alert">
          {t('email_required')}
        </span>
      )}

      <label htmlFor="login_password">{t('password')}</label>
      <input
        id="login_password"
        name="password"
        type="password"
        ref={register({
          required: true,
          validate: (value) => value.length >= 6
        })}
      />
      {errors.password && (
        <span className="form-error" role="alert">
          {t('password_required')}
        </span>
      )}

      <button type="submit" data-testid="login_submit">
        {t('login_action')}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Adicionamos as validações de tamanho de E-mail e senha no formulário de login. Vejamos se os nossos testes voltaram a passar:

$ pnpm run test

> jest --no-cache

 PASS  src/components/Login.spec.jsx
 PASS  src/components/forms/LoginForm.spec.jsx (7.733 s)

Test Suites: 2 passed, 2 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        9.024 s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

Considerações finais

Espero ter te ajudado com dicas sobre testes de componentes React. De fato nenhum assunto aqui foi levado a fundo, cada um dos tipos de testes apresentados nesse texto merecem um artigo inteiro dedicado.

Você pode checar o código fonte nesse repositório no GitHub. Ficou com alguma dúvida ou gostaria de contribuir com mais casos de testes? Sinta-se a vontade para mandar uma DM no Twitter ou abrir uma pull request no GitHub.

Ah, antes de terminar essa leitura, você chegou a notar que nenhuma vez checamos a interface? Não usamos o navegador para testar nosso código. Esse repositório nem tem um servidor de desenvolvimento, muito menos um bundler configurado. Talvez seja legal pensar se realmente precisamos sair do editor para testar se o código que estamos escrevendo está funcionando.

FAQ

  • Porquê esse projeto não foi criado com Create React App? Infelizmente, até o momento onde esse artigo foi escrito o CRA não tinha suporte a abstrações mais modernas do Testing Library, como a função waitFor. Achei mais interessante configurar um projeto para evitar confusões.

Top comments (1)

Collapse
 
andersondavid profile image
Anderson David SF

Cara vlw de mais, eu aprendi muito nesse artigo, conceitos que nem eram o foco em si. Só não intendi a questão do 'login-handler.js', eu crio ele porem invés de importa-lo ou executa-lo de alguma forma, eu apenas importo o .json do usuario?