Testy przy użyciu pytest

Pisanie testów jednostkowych i ogólnie Test Driven Development (TDD) są jednymi z lepszych dobrych praktyk w tworzeniu oprogramowanie, bez względu na technologię. Python posiada wbudowany moduł do tworzenia testów - unittest, który jest niezły, ale chciałbym wam pokazać dzisiaj inną metodę - przy użyciu pytest.

Ustawiamy pytest

Aby zacząć korzystać z pytest powinniście użyć narzędzia pip, z następującą komendą w wybranym środowisku (lub w głównej dystrybucji Python, jeżeli czujecie się odważnie):

pip install pytest

Dobrą praktyką jest od razu dodanie tego do requirements.txt projektu (a jeszcze lepiej gdy stworzycie oddzielny taki plik dla testów).

Piszemy pierwszy test

# my_first_test.py
import pytest

# Tworzymy funkcję, która doda dwie liczby
def add_numbers(a, b):
    return a + b

# Sprawdzamy czy funkcja zwraca poprawny wynik
def test__adding_numbers():
    assert add_numbers(2, 3) == 5

Jeżeli tworzyłeś wcześniej testy przy użyciu unittest, nie zobaczysz zbytnio różnicy. Kod, który tu stworzyliśmy jest taki sam. Pierwszą różnicę zauważysz, gdy odpalisz testy używając następującej komendy w tym samym folderze, który zawiera my_first_test.py:

pytest

Używając tej komendy zobaczycie podsumowanie wszystkich testów, które zostały przeprowadzone i informacje czy przeszły te testy pozytywnie. W naszym prostym przykładzie powinniście dostać wiadomość w stylu: 1 passed in 0.03 seconds.

Pytest result example Przykład podsumowanie wygenerowanego w pytest.

Co dalej?

Oczywiście powyższy przykład nie wnosi nic do naszego życia, prawda? Spróbujmy dodać parametryzację, fixtures oraz inne testy. Załóżmy, że mamy klasę, które reprezentuje konto bankowe jak poniżej:

class Account():
    def __init__(self):
        self.balance = 0

    def change_balance(self, delta):
        self.balance = self.balance + delta    

Na pierwszy rzut oka widać, że przykład jest zły. Przykładowo, co się stanie gdy użytkownik zejdzie poniżej limitu?

Zaczniemy od dodania pytest fixture - pozwalającego na stworzenie kodu, który będzie wywoływany przed każdym testem (podobne do unittest setUp). W naszym przypadku będzie to puste konto bankowe, na którym będziemy mogli przeprowadzić różne operacje (zauważcie dekorator fixture).

@pytest.fixture
def bank_account():
    return Account()

Teraz powinniśmy sprawdzić, czy funkcja change_balance działa poprawnie. Użyjmy do tego dekoratora parametrize, by sprawdzić kilka operacji w jednym przebiegu, w następujący sposób:

# Zauważcie jak używamy różnych parametrów
# Musimy przekazać nasze parametry do funkcji testowej
@pytest.mark.parametrize(
    'income,expense,balance',
    [
        (20, -10, 10),
        (30, 0, 30),
        (10, -10, 0),
    ]
)
def test__changing_the_balance(bank_account, income, expense, balance):
    bank_account.change_balance(income)
    bank_account.change_balance(expense)
    assert bank_account.balance == balance

Ale zaraz… coś tu nie gra. Sprawdzamy tylko, czy operacje działają poprawnie. Do tej pory nie sprawdziliśmy, czy kod pozwala nam wyjść poza limit. Musimy sobie z tym poradzić ASAP. Chcemy rzucić wyjątkiem, gdy funkcja będzie próbowała zmienić nasze środki poniżej 0. Aby to sprawdzić, stwórzmy nowy błąd nazwany NoCashException:

class NoCashException(Exception):
    pass

Teraz tworzymy nasz test:

def test__no_cash_for_operation(bank_account)
    with pytest.raises(NoCashException):
        bank_account.change_balance(-20)

Oczywiście nie przejdziemy tego testu, ponieważ nie zaktualizowaliśmy funkcji change_balance:

def change_balance(self, delta):
    result = self.balance + delta
    if result < 0:
        raise NoCashException()
    self.balance = result

Super, teraz nasze konto bankowe działa tak jak powinno.

Pisanie testów jednostkowych jest łatwe i powinno być pierwszą rzeczą, którą robicie w projekcie. Kiedy zaczynacie pisać funkcje, od razu wiecie jak mają działać, przez co pisanie testów opartych na tej wiedzy pozwala na o wiele łatwiejszą implementację kodu.

A tak to wygląda w całości

import pytest


class NoCashException(Exception):
    pass


class Account():
    def __init__(self):
        self.balance = 0

    def change_balance(self, delta):
        result = self.balance + delta
        if result < 0:
            raise NoCashException()
        self.balance = result


@pytest.fixture
def bank_account():
    return Account()


@pytest.mark.parametrize(
    'income,expense,result',
    [
        (20, -10, 10),
        (30, 0, 30),
        (10, -10, 0),
    ]
)
def test__changing_the_balance(bank_account, income, expense, result):
    bank_account.change_balance(income)
    bank_account.change_balance(expense)
    assert bank_account.balance == result


def test__no_cash_for_operation(bank_account):
    with pytest.raises(NoCashException):
        bank_account.change_balance(-20)