3.1. Pytest para cientista de dados#

Fonte dos vídeos

Todos os vídeos são da própria autora do livro original.

3.1.1. O que é o Pytest?#

Pytest é um framework que facilita a escrita de testes unitários em Python. A autora original do livro afirma gostar do pytest pois permite a escrita de teses com pouco código, e eu, autor da tradução, concordo plenamente com ela. Se você já está familiarizado com testes unitários, pytest é uma grande ferramenta para sair da inércia.

Para instalar o pytest, basta rodar no terminal:

pip install pytest

Caso estiver usando o poetry:

poetry add pytest

Para testar a função mostrada anteriormente, nós podemos simplesmente criar outra função que se inicia com test_ seguida do nome da função que desejamos testar, que é extract_sentiment.

# sentiment.py
from textblob import TextBlob

def extract_sentiment(text: str):
        '''Extrai um sentimento usando textblob. 
        Polaridade está no intervalo [-1, 1]'''

        text = TextBlob(text)

        return text.sentiment.polarity

def test_extract_sentiment():

    # Tradução: Eu acredito que hoje será um grande dia
    text = "I think today will be a great day"

    sentiment = extract_sentiment(text)

    assert sentiment > 0

Nota

Até o momento da tradução deste livro, a biblioteca textblob não apresenta suporte à língua portuguesa.

Dentro da função de teste, nós usamos a função extract_sentiment em uma frase de exemplo: “I think today will be a great day”, que em português significa “Eu acredito que hoje será um grande dia”. Nós, então, usamos um assert sentiment > 0 para garantir que aquele sentimento é positivo, pois esta é nossa expectativa.

E tudo pronto! Hora de rodar nosso teste.

Se o nome do nosso script for sentiment.py, podemos rodar:

pytest sentiment.py

O pytest irá procurar e rodar por todas as funções que começam com test_ no script. A saída do teste acima será parecida com esta:

========================================== 1 passed in 0.68s ===========================================

Legal, não? Não precisamos nem especificar qual função rodar. Desde que colocamos test_ no começo da função de teste, o pytest detecta e executa automaticamente aquela função. Não precisamos nem sequer importar o pytest no script para rodar os testes.

Qual será a saída caso algum dos testes do pytest falhar?

#sentiment.py

def test_extract_sentiment():

    text = "I think today will be a great day"

    sentiment = extract_sentiment(text)

    assert sentiment < 0
$ pytest sentiment.py
_______________________________________ test_extract_sentiment ________________________________________

def test_extract_sentiment():
    
        text = "I think today will be a great day"
    
        sentiment = extract_sentiment(text)
    
>       assert sentiment < 0
E       assert 0.8 < 0
========================================== 1 failed in 0.84s ===========================================

A partir da saída acima, podemos perceber que o teste falhou porque o sentimento retornado pela função é 0.8 e não é menor do que zero! Nós agora somos capazes de não só saber se a nossa função funciona como esperado, mas também o porquê ela não funciona. Com este insight maravilhoso, nós conseguimos consertar a nossa função para que ela funcione de acordo com o esperado.

3.1.2. Múltiplos testes para a mesma função#

Nós gostaríamos de testar a nossa função com outros exemplos. Como seriam os nomes destes novos testes?

O nome do segundo teste poderia ser algo do tipo test_extract_sentiment_2 ou test_extract_sentiment_negative caso o teste seja com um texto com sentimento negativo. O importante é que qualquer nome significativo é valido, desde que comece com test_:

#sentiment.py

def test_extract_sentiment_positive():

    # Tradução: Eu acredito que hoje será um grande dia
    text = "I think today will be a great day"

    sentiment = extract_sentiment(text)

    assert sentiment > 0

def test_extract_sentiment_negative():

    # Tradução: Isso não vai acabar bem
    text = "I do not think this will turn out well"

    sentiment = extract_sentiment(text)

    assert sentiment < 0
$ pytest sentiment.py
___________________________________ test_extract_sentiment_negative ____________________________________

def test_extract_sentiment_negative():
    
        # Tradução: Isso não vai acabar bem
        text = "I do not think this will turn out well"
    
        sentiment = extract_sentiment(text)
    
>       assert sentiment < 0
E       assert 0.0 < 0
===================================== 1 failed, 1 passed in 0.80s ======================================

O resultado dos testes nos mostra que apenas um dos testes passou e o outro falhou, e sabemos o porquê. Esperávamos que a saída da sentença “I do not think this will turn out well”, que em português significa “Isso não vai acabar bem”, fosse negativo, mas a função retornou 0, classificando a frase como neutra.

Esse tipo de análise nos indica que a função pode não ter uma assertividade de 100 % e, portanto, devemos ter cuidado ao usá-la para extrair sentimentos de um texto.

3.1.3. Parametrização: combinando testes#

O exemplo acima mostra 2 testes para a mesma função. Será que não podemos combinar os 2 exemplos em uma única função de testes? É o que vamos aprender com parametrização de testes.

3.1.3.1. Parametrizando com uma lista de exemplos#

Com o decorador pytest.mark.parametrize(), podemos executar testes com diferentes exemplos passando uma lista como argumento.

# sentiment.py

from textblob import TextBlob
import pytest

def extract_sentiment(text: str):
        '''Extract sentiment using textblob. 
        Polarity is within range [-1, 1]'''

        text = TextBlob(text)

        return text.sentiment.polarity

# Traduções: "Eu acredito que hoje será um grande dia", "Isso não vai acabar bem"
testdata = ["I think today will be a great day","I do not think this will turn out well"]

@pytest.mark.parametrize('sample', testdata)
def test_extract_sentiment(sample):

    sentiment = extract_sentiment(sample)

    assert sentiment > 0

No código acima, nós associamos à variável sample uma lista de frases, e então, passamos essa variável como argumento da função de teste. Agora cada frase da lista será testada uma de cada vez. Vejamos abaixo:

_____ test_extract_sentiment[I do not think this will turn out well] _____

sample = 'I do not think this will turn out well'
@pytest.mark.parametrize('sample', testdata)
    def test_extract_sentiment(sample):
    
        sentiment = extract_sentiment(sample)
    
>       assert sentiment > 0
E       assert 0.0 > 0
====================== 1 failed, 1 passed in 0.80s ===================

Usando parametrize() é possível testar 2 exemplos diferentes na mesma função!

3.1.3.2. Parametrizando uma lista de entradas diferentes com diferentes resultados esperados#

E se nós testássemos diferentes exemplos para ter diferentes saídas? O pytest permite também adicionar exemplos e resultados esperados como argumentos da nossa função de teste!

Por exemplo, a função abaixo checa se um texto contém uma determinada palavra.

def text_contain_word(word: str, text: str):
    '''Procura por uma determinada palavra em um texto'''
    
    return word in text

Ela deve retornar True se o texto ou frase contiver a palavra.

Caso a palavra seja “pato” e o texto for “Há um pato no meio do texto”, nossa expectativa é que a função retorne True.

Por outro lado, se a palavra for “pato” e o texto agora for “Não há nada aqui”, nossa expectativa é que a função retorne False.

Vamos usar o parametrize(), mas com uma lista de tuplas agora.

# process.py
import pytest
def text_contain_word(word: str, text: str):
    '''Procura por uma determinada palavra em um texto'''
    
    return word in text

testdata = [
    ('Há um pato no meio do texto',True),
    ('Não há nada aqui', False)
    ]

@pytest.mark.parametrize('sample, expected_output', testdata)
def test_text_contain_word(sample, expected_output):

    word = 'pato'

    assert text_contain_word(word, sample) == expected_output

A estrutura dos parâmetros da nossa função será parametrize(‘sample, expected_out’, testdata) com testdata=[(<exemplo1>, <saida1>), (<exemplo2>, <saida2>)

$ pytest process.py

========================================== 2 passed in 0.04s ===========================================

Maravilha! Ambos os testes passaram!

3.1.4. Testando uma função de cada vez#

Conforme o número de testes em nosso script começa a crescer, você pode desejar testar uma única função ao invés de vários ou todos de uma única vez. Isso pode ser feito através da sintaxe pytest arquivo.py::nome_da_funcao_de_teste.

# process.py

testdata = ["I think today will be a great day","I do not think this will turn out well"]

@pytest.mark.parametrize('sample', testdata)
def test_extract_sentiment(sample):

    sentiment = extract_sentiment(sample)

    assert sentiment > 0


testdata = [
    ('Há um pato no meio do texto',True),
    ('Não há nada aqui', False)
    ]

@pytest.mark.parametrize('sample, expected_output', testdata)
def test_text_contain_word(sample, expected_output):

    word = 'pato'

    assert text_contain_word(word, sample) == expected_output

Por exemplo, se quisermos rodar apenas o teste test_text_contain_word, basta rodar no terminal:

$ pytest process.py::test_text_contain_word

E o pytest executará apenas o teste especificado!

3.1.5. Fixtures: usando os mesmos dados para testar diferentes funções#

E se quiséssemos usar os mesmos dados para testar diferentes funções? Por exemplo, gostaríamos de usar a frase “Today I found a duck and I am happy” para testar se ela contém a palavra “duck” e também se seu sentimento é positivo. A ideia é aplicar as duas funções à mesma frase “Today I found a duck and I am happy”. É quando as fixture mostram o seu potencial.

As fixtures do pytest são uma forma de usar os mesmos dados para duas ou mais funções diferentes.

@pytest.fixture
def example_data():
    return 'Today I found a duck and I am happy'


def test_extract_sentiment(example_data):

    sentiment = extract_sentiment(example_data)

    assert sentiment > 0

def test_text_contain_word(example_data):

    word = 'duck'

    assert text_contain_word(word, example_data) == True

No exemplo acima, criamos uma frase de exemplo com o decorador @pytest.fixture acima da função example_data. Essa função transforma example_data em uma variável com o valor “Today I found a duck and I am happy”

Agora conseguimos usar essa variável como parâmetro de quaisquer outros testes!

3.1.6. Estruture seus projetos#

Por último, mas não menos importante, conforme o nosso código cresce, é ideal que coloquemos as nossas funções de ciência de dados e os seus respectivos testes em duas pastas diferentes. Isso facilita a localização de cada função e do teste correspondente.

Nomeando as funções de teste seja com test_<nome_da_funcao>.py ou <nome_da_funcao>_test.py, o pytest encontrará os scripts que começam ou terminam com test e executará as funções associadas a cada teste.

Há diferentes formas de organizar seus arquivos. Você pode ou organizar seus scripts e funções na mesma pasta ou em duas pastas diferentes, uma com o código-fonte e outra com os testes.

Método 1:

test_structure_example/
├── process.py
└── test_process.py

Método 2:

test_structure_example/
├── src
│   └── process.py
└── tests
    └── test_process.py

Tanto eu, autor da tradução, como a autora original recomendamos o método dois, pois torna mais fácil a organização conforme um projeto cresce.

A organização do código e teste no método 2 ficará desta forma:

# src/process.py
from textblob import TextBlob

def extract_sentiment(text: str):
        '''Extract sentiment using textblob. 
        Polarity is within range [-1, 1]'''

        text = TextBlob(text)

        return text.sentiment.polarity
# tests/test_process.py
import sys
import os.path
sys.path.append(
    os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)))
from src.process import extract_sentiment
import pytest


def test_extract_sentiment():

    text = 'Today I found a duck and I am happy'

    sentiment = extract_sentiment(text)

    assert sentiment > 0

O objetivo do comando sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) é simplesmente adicionar o módulo src à variável de ambiente PYTHONPATH para permitir importações dos módulos e funções.

Estando na pasta raiz do projeto (test_structure_example/), rode pytest tests/test_process.py ou então pytest test_process.py.

=========================== 1 passed in 0.69s ============================