Testes automatizados

Código-fonte construído durante a apresentação da palestra "Testes automatizados - O que são e um exemplo prático com Python, Flask e Pytest".

Link p/ o vídeo completo: https://www.youtube.com/watch?v=pkpSKoq09cU

Link p/ a apresentação: https://docs.google.com/presentation/d/1j_obQoPOe7Zb41CHttoIrmP4oqYKYCGK8Y9RcMa8eVQ/edit?usp=sharing

Passo a passo da parte prática

Siga as instruções abaixo em um diretório vazio para replicar a parte prática da palestra. Note que o roteiro assume que algumas coisas estão disponíveis no seu sistema operacional, sendo elas:

  • Python 3 (incluindo a ferramenta de gestão de pacotes pip).
  • Cliente de linha de comando sqlite3 para interface com o banco de dados.
  • Node.js (incluindo a ferramenta de gestão de pacotes npm).
  • Navegador Firefox usado nos testes E2E.
  • Utilitário geckodriver usado pela biblioteca Selenium para controle remoto do navegador Firefox.
  • Utilitário de linha de comando curl para execução de requisições HTTP.

Acesse o site das respectivas ferramentas para instruções de instalação compatíveis com o seu sistema operacional. Todos os exemplos assumem um terminal Bash à disposição, portanto será necessário adaptá-los para funcionar em outros ambientes.

Descrição do que será desenvolvido

O escopo desta prática é uma página de produtos, mostrando nome do produto e o valor de venda. O valor de venda pode sofrer desconto seguindo as regras abaixo:

  • 10% de desconto se o valor for maior ou igual a R$ 5,00.
  • 5% de desconto se o usuário estiver acessando o site entre terça e quinta (inclusive).

Note que as regras de desconto são acumulativas.

O foco do desenvolvimento está na cobertura das partes com testes unitários e de integração/E2E.

O trabalho será dividido em 3 grandes fases:

  1. Back end (API usando Python, Flask e Pytest)
  2. Front end (usando React)
  3. E2E (usando script Bash e Selenium)

Execute os passos em um diretório vazio.

Back end

Comece criando um diretório chamado back:

mkdir back

Acesse o diretório e prepare um ambiente virtual isolado usando o módulo venv do Python 3:

cd back
python -m venv venv
source venv/bin/activate

Nota: se você sair do terminal ou abrir outro, terá que repetir o comando source venv/bin/activate para voltar a trabalhar dentro do ambiente virtual.

Instale agora o micro web framework flask:

pip install flask flask-cors

Abra o diretório back no editor de sua preferência, e crie um arquivo chamado app.py com o seguinte conteúdo:

from flask import Flask, jsonify
from flask_cors import CORS


app = Flask(__name__)
CORS(app)


@app.route('/ping', methods=['GET'])
def ping():
  return '', 204


@app.route('/produtos', methods=['GET'])
def produtos():
  return jsonify([
    {
      "id": 1,
      "nome": "Lápis",
      "valor": 4.5,
      "desconto": 0
    }
  ])

Execute o servidor no modo de desenvolvimento utilizando o seguinte comando:

python -m flask run

Em um outro terminal, execute as seguintes chamadas curl para verificar se deu tudo certo:

$ curl -v http://localhost:5000/ping
[...]
< HTTP/1.0 204 NO CONTENT
[...]

$ curl http://localhost:5000/produtos | jq
[...]
[
  {
    "desconto": 0,
    "id": 1,
    "nome": "Lápis",
    "valor": 4.5
  }
]

Nota: não é obrigatório o uso do utilitário jq, mas com ele a saída vem formatada resultando em uma visualização mais agradável.

Ainda no arquivo app.py, dinamize a saída usando uma base de dados sqlite3. Comece adicionando um evento responsável por inicializar a base com a tabela de produtos antes da primeira requisição servida pela API:

import sqlite3

# ...

@app.before_first_request
def init_db():
  arquivo_db = app.config.get('ARQUIVO_BD', 'app.db')
  conexao = sqlite3.connect(arquivo_db)
  conexao.execute('''
    create table if not exists produtos (
      id integer primary key,
      nome text not null,
      valor_em_centavos integer not null
    );
  ''')

# ...

Reinicie o servidor e teste novamente uma das chamadas curl. Um arquivo app.db deve aparecer na pasta back e nenhum erro deve acontecer.

Utilizando o utilitário sqlite3 insira alguns produtos na tabela criada:

$ sqlite3 app.db
SQLite version 3.33.0 2020-08-14 13:23:32
Enter ".help" for usage hints.
sqlite> insert into produtos (id, nome, valor_em_centavos) values (1, 'Lápis', 350);
sqlite> insert into produtos (id, nome, valor_em_centavos) values (2, 'Mesa', 70000);

Volte no app.py e finalize a implementação do método produtos:

# ...

def abrir_conexao():
  arquivo_db = app.config.get('ARQUIVO_BD', 'app.db')
  return sqlite3.connect(arquivo_db)


@app.before_first_request
def init_db():
  conexao = abrir_conexao()
  conexao.execute('''
    create table if not exists produtos (
      id integer primary key,
      nome text not null,
      valor_em_centavos integer not null
    );
  ''')

# ...

@app.route('/produtos', methods=['GET'])
def produtos():
  conexao = abrir_conexao()

  produtos = conexao.execute('''
    select id, nome, valor_em_centavos
    from produtos
    order by nome
  ''')

  return jsonify([
    {
      "id": produto[0],
      "nome": produto[1],
      "valor": produto[2] / 100,
      "desconto": 0
    }
    for produto in produtos
  ])

Execute novamente o curl de busca de produtos.

O próximo passo é implementar o cálculo de desconto:

# ...

def calcular_desconto(valor_em_centavos):
  porcentagem = 0
  if valor_em_centavos >= 500:
    porcentagem = 0.1
  hoje = date.today()
  dia_da_semana = hoje.weekday()
  if dia_da_semana >= 1 and dia_da_semana <= 3:
    porcentagem += 0.05
  return valor_em_centavos * porcentagem


@app.route('/produtos', methods=['GET'])
def produtos():
  conexao = abrir_conexao()

  produtos = conexao.execute('''
    select id, nome, valor_em_centavos
    from produtos
    order by nome
  ''')

  return jsonify([
    {
      "id": produto[0],
      "nome": produto[1],
      "valor": produto[2] / 100,
      "desconto": calcular_desconto(produto[2]) / 100
    }
    for produto in produtos
  ])

Agora é possível começar a cobrir este código com testes. Dê uma olhada no método calcular_desconto. Ele possui muitas regras de negócio, então é um bom candidato para começarmos. Instale a biblioteca pytest:

pip install pytest

Crie um arquivo app_test.py com o seguinte código:

from app import calcular_desconto


def test_calcular_desconto_valor_200():
  desconto = calcular_desconto(200)
  assert desconto == 0.0 # ou 0.0 dependendo do dia :(

Teste com o comando pytest no terminal.

Obviamente não é aceitável uma automatização que só funciona em dias específicos da semana. Como resolver? O problema é que o conceito de dia atual é um efeito colateral da função calcular_desconto, que hoje não é pura. O jeito mais fácil e recomendado de resolver é transformar o dia de referência um parâmetro da função. Comece alterando no arquivo app.py:

# ...

def calcular_desconto(valor_em_centavos, dia_referencia):
  porcentagem = 0
  if valor_em_centavos >= 500:
    porcentagem = 0.1
  dia_da_semana = dia_referencia.weekday()
  if dia_da_semana >= 1 and dia_da_semana <= 3:
    porcentagem += 0.05
  return valor_em_centavos * porcentagem

# ...
  return jsonify([
    {
      "id": produto[0],
      "nome": produto[1],
      "valor": produto[2] / 100,
      "desconto": calcular_desconto(produto[2], date.today()) / 100
    }
    for produto in produtos
  ])
# ...

E por fim no app_test.py:

from datetime import date
from app import calcular_desconto


def test_calcular_desconto_valor_200_segunda_feira():
  desconto = calcular_desconto(200, date(2000, 1, 3))
  assert desconto == 0.0


def test_calcular_desconto_valor_200_terca_feira():
  desconto = calcular_desconto(200, date(2000, 1, 4))
  assert desconto == 10.0


def test_calcular_desconto_valor_500_segunda_feira():
  desconto = calcular_desconto(500, date(2000, 1, 3))
  assert desconto == 50.0


def test_calcular_desconto_valor_500_terca_feira():
  desconto = calcular_desconto(500, date(2000, 1, 4))
  assert desconto == 75.0

O problema da data atual foi resolvido, mas o último caso de teste continua não funcionando. Matemática com pontos flutuantes em computação é sempre complicada, e este é um exemplo do que pode acontecer. Via de regra não se deve verificar igualdade de ponto flutuante utilizando ==, mas sim testando a diferença absoluta dos dois números, dessa forma:

def test_calcular_desconto_valor_500_terca_feira():
  desconto = calcular_desconto(500, date(2000, 1, 4))
  assert abs(75.0 - desconto) < 0.001

Essa prática é tão comum que o próprio pytest (e qualquer framework de testes que se preze) fornece um utilitário:

import pytest

# ...

def test_calcular_desconto_valor_500_terca_feira():
  desconto = calcular_desconto(500, date(2000, 1, 4))
  assert desconto == pytest.approx(75)

Dica: suba o servidor novamente e teste o comando curl antes de prosseguir.

E como testar a camada das requisições HTTP? Felizmente o Flask possui um bom suporte para testes. O primeiro passo é criar uma fixture do Pytest capaz de efetuar requisições à aplicação Flask. Para isso crie um arquivo conftest.py com o seguinte conteúdo:

import pytest

from app import app


@pytest.fixture
def client():
  app.config['TESTING'] = True
  with app.test_client() as client:
    yield client

E adicione o seguinte caso de teste no arquivo app_test.py:

def test_ping(client):
  resposta = client.get('/ping')
  assert resposta.status_code == 204

Mas e o endpoint que retorna produtos? Ele possui a complicação de depender do banco de dados. Testes automatizados só são úteis se forem fácil de executar repetidamente, e ter que preparar a base de dados manualmente antes de cada execução não é muito compatível com esse princípio.

A abordagem será outra. O pytest irá criar e inicializar uma base de dados nova para cada caso de teste. Assim cada caso de teste pode inserir os dados que precisa para a chamada que está sendo testada, e nada além disso.

Faça as seguintes alterações no arquivo conftest.py:

import os
import tempfile
import pytest

from app import app, init_db


@pytest.fixture
def client():
  fd, caminho = tempfile.mkstemp()
  app.config['ARQUIVO_BD'] = caminho
  app.config['TESTING'] = True

  with app.test_client() as client:
    with app.app_context():
      init_db()
    yield client

  os.close(fd)
  os.unlink(caminho)

E escreva o seguinte caso de teste no arquivo app_test.py:

def test_produtos(client):
  with abrir_conexao() as conexao:
    conexao.execute('''
      insert into produtos (id, nome, valor_em_centavos)
      values (1, 'Papel', 230);
    ''')
  resposta = client.get('/produtos')
  assert resposta.status_code == 200
  assert resposta.json == [
    {
      "id": 1,
      "nome": "Papel",
      "valor": 2.3,
      "desconto": pytest.approx(0.0) # depende do dia :(
    }
  ]

Novamente existe o problema do efeito colateral na chamada date.today(). Dessa vez é mais difícil resolver via passagem de parâmetros, então é necessário uma outra solução. Para este problema específico existe uma biblioteca python chamada freezegun, capaz de mocar várias funções nativas relacionadas com o horário atual do sistema. Comece instalando a biblioteca:

pip install freezegun

E decorando o caso de teste que precisa de data fixa:

# ...
from freezegun import freeze_time

# ...
@freeze_time('2000-01-04')
def test_produtos(client):
  with abrir_conexao() as conexao:
    conexao.execute('''
      insert into produtos (id, nome, valor_em_centavos)
      values (1, 'Papel', 230);
    ''')
  resposta = client.get('/produtos')
  assert resposta.status_code == 200
  assert resposta.json == [
    {
      "id": 1,
      "nome": "Papel",
      "valor": 2.3,
      "desconto": pytest.approx(0.115)
    }
  ]

Front end

Volte para a pasta inicial (saindo da pasta back) e crie um projeto em branco usando create-react-app:

npx create-react-app front

Navegue para dentro do diretório front e execute o seguinte comando para testar se deu certo:

npm start

A página de boas-vindas do CRA deve aparecer no seu navegador.

Execute também a suíte de teste que foi criada junto com o projeto:

npm test

Abra o diretório front no seu editor e abra o arquivo src/App.test.js e veja seu conteúdo. Abra agora o arquivo src/App.js e modifique-o para mostrar a lista de produtos do back end:

import { useEffect, useState } from 'react';


function App() {
  const [ produtos, setProdutos ] = useState([]);

  useEffect(() => {
    (async () => {
      const resposta = await fetch('http://localhost:5000/produtos');
      const produtos = await resposta.json();
      setProdutos(produtos);
    })();
  }, []);

  return (
    <ul>
      {produtos.map(produto => (
        <li key={produto.id}>
          {produto.nome} (R$ {produto.valor - produto.desconto})
        </li>
      ))}
    </ul>
  );
}

export default App;

Nota: lembre-se de deixar o back end rodando antes de testar o front end.

Se você testar agora o comando npm test vai reparar que o teste obviamente quebrou, pois o componente App foi completamente modificado. Na tentativa de resolver, no entanto, você vai se deparar com um problema bem chato: como garantir a lista de produtos retornada pela API?

A resposta é que estes testes, chamados de integração/E2E, são complicados de escrever/executar e devem compor a menor parte dos esforços, sendo que a grande maioria pode ser escrita sem se preocupar com serviços externos. Neste caso é possível resolver extraindo a renderização dos produtos para um componente chamado Produtos. Comece criando um arquivo chamado Produtos.js com o seguinte conteúdo:

function Produtos ({ produtos }) {
  return (
    <ul>
      {produtos.map(produto => (
        <li key={produto.id}>
          {produto.nome} (R$ {produto.valor - produto.desconto})
        </li>
      ))}
    </ul>
  );
}


export default Produtos;

E ajustando o App.js:

import { useEffect, useState } from 'react';
import Produtos from './Produtos';


function App() {
  const [ produtos, setProdutos ] = useState([]);

  useEffect(() => {
    (async () => {
      const resposta = await fetch('http://localhost:5000/produtos');
      const produtos = await resposta.json();
      setProdutos(produtos);
    })();
  }, []);

  return (
    <Produtos produtos={produtos} />
  );
}

export default App;

Dica: antes de continuar garanta que a aplicação esteja funcionando.

O próximo passo é renomear o arquivo App.test.js para Produtos.test.js e ajustar o teste dentro dele:

import { render, screen } from '@testing-library/react';
import Produtos from './Produtos';

test('renderiza um produto', () => {
  const produtos = [
    {
      "id": 1,
      "nome": "Monitor",
      "valor": 3510.99,
      "desconto": 5.00
    }
  ];
  render(<Produtos produtos={produtos} />);
  const elemento = screen.getByText('Monitor (R$ 3505.99)');
  expect(elemento).toBeInTheDocument();
});

O que foi feito aqui é basicamente a criação de um componente puro (Produtos) facilmente testável. Note que mais uma vez a escrita de testes promove escrita de código com maior qualidade.

End-to-end (E2E)

O último passo é testar a comunicação entre o back end e front end. Para isso é necessário executar os dois ao mesmo tempo, colocar o back end em uma situação conhecida (manipulação do banco de dados), exercitar o front end direto no navegador de forma automática e validar os resultados, fechando tudo ao final da execução. Quanta coisa!

Comece criando um diretório e2e ao lado dos diretórios back e front. Abra ele no seu editor. Crie um arquivo e2e.sh e dê permissões de execução:

touch e2e.sh
chmod +x e2e.sh

O primeiro passo é preparar o script para finalizar forçadamente sua execução em caso de erro e cascatear a finalização para todos os processos filhos criados em background:

Atenção: em alguns shells a instrução trap abaixo pode encerrar sua sessão X/Wayland, execute com cuidado e salve sempre seus arquivos antes de executar código da Internet :).

#!/bin/bash

trap "kill 0" EXIT

set -e

Agora é possível inicializar o back end. Lembre-se que por padrão ele vai usar a base de dados app.db no diretório ativo. É possível tirar proveito disso, forçar uma chamada ao endpoint ping (isso vai causar a inicialização da base) e inserir a massa de dados:

#!/bin/bash

trap "kill 0" EXIT

set -e

rm -f app.db

source ../back/venv/bin/activate
PYTHONPATH=../back python -m flask run &
sleep 2 # dê um tempo para ele inicializar
curl http://localhost:5000/ping
sqlite3 app.db "insert into produtos (id, nome, valor_em_centavos) values (1, 'Bala', 300);"

echo "Testando..."
sleep 5

Atenção: lembre-se de desligar instâncias do back e front em outros terminais antes de rodar o e2e, para que não haja conflito de portas.

O próximo passo é similar e envolve a subida do front end:

# ...

cd ../front
BROWSER=none npm start &
cd ../e2e
sleep 2

echo "Testando..."
sleep 5

O último passo é a escrita do teste em si. Para isso será utilizada a biblioteca Selenium WebDriver. Comece instalando a dependência via pip (lembre-se de fazer isso com o venv ativado no terminal!):

pip install selenium

Crie agora um arquivo chamado e2e.py com o seguinte conteúdo:

from selenium import webdriver


try:
  driver = webdriver.Firefox()
  driver.implicitly_wait(2) # segundos
  driver.get('http://localhost:3000')

  # se o elemento não for encontrado vai dar exceção
  driver.find_element_by_xpath('//li[contains(text(), "Bala")]')

finally:
  driver.close()

E chame esse script no arquivo e2e.sh:

# ...

python e2e.py

Sugestão: force uma falha (trocando o nome do produto no arquivo e2e.py, por exemplo) para garantir que está funcionando. Isso vale para qualquer escrita de teste automatizado. Não confie no verde!