Mateusz Mazurek – programista z pasją

Python, architektura, ciekawostki ze świata IT

Inżynieria oprogramowania Programowanie Programowanie webowe

Python: testowanie w duchu BDD

Dziś pokażę naprawdę ciekawe podejście do testowania. Co prawda blask BDD (ang. Behavior Driven Development) trochę już przygasł, ale koncepcja ta zostawiła po sobie kilka fajnych pomysłów.

Czym jest BDD?

Aby artykuł był kompletny nie mogę ominąć krótkiego wprowadzenia do tematu. BDD to podejście bliskie TDD z tym, że zamiana pierwszej literki tego akronimu ciągnie za sobą sporo konsekwencji. TDD to skrót od Test Driven Development, który zakłada, że testy piszemy przed kodem. Testy te na samym początku oczywiście nie przechodzą i dopiero w trakcie pisania konkretnej funkcjonalności, powoli zaczynają się mienić zielonym kolorem.

BDD natomiast rozwija się do Behavior Driven Development. Zamiana literki T na B powoduje, że sterować rozwojem oprogramowania nie będą testy, ale zachowania.

Test a zachowanie

Test to po prostu kawałek kodu, który zakłada, że mamy jakąś wartość oczekiwaną i porównujemy ją z jakąś wartością wynikową, sprawdzając tym samym, czy mechanizm, który wyprodukował ową wynikową wartość, działa zgodnie z naszym oczekiwaniem.

Zachowanie natomiast nie jest kawałkiem kodu jako takim, a tekstem, który określa, jak ma się dana funkcjonalność zachowywać, np.:

given: jako zalogowany użytkownik internetowego serwisu bankowego, który ma na koncie kwotę X
when: próbując wykonać przelew na kwotę > X
then: powinienem otrzymać komunikat o braku możliwości wykonania transakcji.

gdzie znaczenie poszczególnych kroków można opisać mniej więcej tak:

given – określa warunki początkowe, przedstawia uczestnika, zapoznaje odbiorcę z sytuacją,
when – opisuje akcje, występujące zdarzenie,
then – informuje o oczekiwanych rezultatach.

Gdzie zysk?

Jeśli klient zrozumie ideę, będzie nam w stanie opisać oczekiwaną funkcjonalność, korzystając z przedstawionego powyżej schematu. A od tego już krok do próby parsowania takiego tekstu, bo przecież jest schematyczny. A co jak co, ale w pracy nad schematycznymi danymi programiści są naprawdę nieźli :)

Idąc dalej, to przecież nic nie jest lepszym scenariuszem testów niż specyfikacja, a skoro jest ona dostarczona w sposób schematyczny, to niech ona stanie się tym scenariuszem! Ach, gdyby tylko istniało coś co potrafi sprawnie „wykonać” tekst zapisany w takiej formie…

… Ach, przecież takie coś powstało! I daje nam możliwość skrócenia procesu dostarczania oprogramowania, ponieważ nie musimy jako programiści tłumaczyć prozy opisującej funkcjonalność na właściwe testy a i zmniejszamy tym samym ilość niedomówień w specyfikacjach.

A cóż to takiego?

Język określający składnię „given…” nazywa się Gherkin i służy do tworzenia przypadków testowych. Jest językiem naturalnym, opisowym, bardzo podobnym do tego, którego używamy na co dzień.

Natomiast biblioteka Python’owa, która radzi sobie z tym językiem to behave.

No to przetestujmy coś!

Załóżmy, że przekonaliśmy klientów, by pisali specyfikację w taki sposób. Dostaliśmy do napisania ficzer, który pozwala na stworzenie produktu i obliczenie, w zależności od podanej stawki VAT, ile wynosi ukryty w tej cenie podatek.

Nasza specyfikacja wygląda tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Feature: calculating vat

  Scenario Outline: create product
    Given I created product called <name> with the price <price> and VAT <vat_rate>
    Then the vat value will be <vat_value>

    Examples: Products with 23%
      | name          | price | vat_rate | vat_value |
      | Mobile        | 1230  | 23       | 230.00    |
      | Computer      | 3600  | 23       | 673.17    |
      | Notebook      | 7999  | 23       | 1495.75   |

    Examples: Products with 5%
      | name             | price  | vat_rate | vat_value |
      | How to be rich?  | 29.99  | 5        | 1.43      |
      | Done Definitions | 59.99  | 5        | 2.86      |
      | Motivation       | 75.99  | 5        | 3.62      |

    Examples: Products with 8%
      | name  | price  | vat_rate | vat_value |
      | Bolt  | 24.89  | 8        | 1.84      |
      | Uber  | 31.34  | 8        | 2.32      |

i nawet dostaliśmy przykłady! Nie wiem czy teraz to już nie poszalałem i wpisu nie powinienem dodać do kategorii „fantasy”:)

Tak czy siak – sprawmy by ten tekst stał się testem!

Doinstalujmy behave’a:

1
pip install behave

i stwórzmy w naszym projekcie folder „features” a w nim folder „steps”. Dzięki takiej strukturze nowo zainstalowana biblioteka ładnie wszystko sobie połączy.

Do folderu „features” dodajmy plik o nazwie „products.features” i wklejmy tam nasze wymagania. I to w sumie tyle. Teraz czas zaprogramować, co będą oznaczać konkretne zdania. Aby było to dla Was przejrzyste, najpierw pokażę jak wygląda testowana klasa:

1
2
3
4
5
6
7
8
9
10
11
12
from dataclasses import dataclass, field


@dataclass
class Product:
    name: str
    price: float
    vat_rate: int
    vat_value: float = field(init=False)

    def __post_init__(self):
        self.vat_value = round((self.price * self.vat_rate) / (100 + self.vat_rate), 2)

Użyłem tu dekoratora dataclass, dzięki czemu nie muszę pisać ręcznie funkcji __init__ a jedyne co dopisałem, to żeby z argumentów konstruktora wykluczył pole vat_value, a w funkcji __post_init__ policzyłem wartość VATu.

To teraz czas na zdefiniowane tego, co konkretne zdania testu mają robić. W folderze „steps” dodajmy plik „products.py” a w nim:

1
2
3
4
5
6
7
8
9
10
11
12
from behave import *
from product import Product


@given("I created product called {name} with the price {price} and VAT {vat}")
def step_impl(context, name, price, vat):
    context.product = Product(name, float(price), int(vat))


@then("the vat value will be {vat_value}")
def step_impl(context, vat_value):
    assert context.product.vat_value == float(vat_value)

i to w sumie tyle. Korzystając z dostarczonych przez behave’a dekoratorów zdefiniowaliśmy funkcje, które mają się wykonać dla konkretnych zdań. Żeby przekaz był jasny – zmienna context przechowuje kontekst aktualnego testu, więc służy właśnie do przekazywania np. obiektu, a reszta argumentów to po prostu wartości „znaczące” dla konkretnego zdania. Odpalając testy poleceniem „behave” dostajemy output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Feature: calculating vat # features/products.feature:1

  Scenario Outline: create product -- @1.1 Products with 23%             # features/products.feature:9
    Given I created product called Mobile with the price 1230 and VAT 23 # features/steps/products.py:5 0.000s
    Then the vat value will be 230.00                                    # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @1.2 Products with 23%               # features/products.feature:10
    Given I created product called Computer with the price 3600 and VAT 23 # features/steps/products.py:5 0.000s
    Then the vat value will be 673.17                                      # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @1.3 Products with 23%               # features/products.feature:11
    Given I created product called Notebook with the price 7999 and VAT 23 # features/steps/products.py:5 0.000s
    Then the vat value will be 1495.75                                     # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @2.1 Products with 5%                       # features/products.feature:15
    Given I created product called How to be rich? with the price 29.99 and VAT 5 # features/steps/products.py:5 0.000s
    Then the vat value will be 1.43                                               # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @2.2 Products with 5%                        # features/products.feature:16
    Given I created product called Done Definitions with the price 59.99 and VAT 5 # features/steps/products.py:5 0.000s
    Then the vat value will be 2.86                                                # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @2.3 Products with 5%                  # features/products.feature:17
    Given I created product called Motivation with the price 75.99 and VAT 5 # features/steps/products.py:5 0.000s
    Then the vat value will be 3.62                                          # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @3.1 Products with 8%            # features/products.feature:21
    Given I created product called Bolt with the price 24.89 and VAT 8 # features/steps/products.py:5 0.000s
    Then the vat value will be 1.84                                    # features/steps/products.py:10 0.000s

  Scenario Outline: create product -- @3.2 Products with 8%            # features/products.feature:22
    Given I created product called Uber with the price 31.34 and VAT 8 # features/steps/products.py:5 0.000s
    Then the vat value will be 2.32                                    # features/steps/products.py:10 0.000s

1 feature passed, 0 failed, 0 skipped
8 scenarios passed, 0 failed, 0 skipped
16 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.001s

informujący, że testy przeszły poprawnie. Behave zastosował podane przykłady do testowanego przypadku. Tzn mając:

1
2
    Given I created product called <name> with the price <price> and VAT <vat_rate>
    Then the vat value will be <vat_value>

w oznaczone miejsce wstawił wartości kolumn z dostarczonych przykładów.

Bardziej złożony przykład, czyli zakładamy firmę!

Teraz przyszedł do nas klient z informacją, że musimy dać możliwość zdefiniowania firmy, która na początku podaje jakim podatkiem dochodowym się rozlicza, a potem możemy kupować i sprzedawać dowolnie produkty. Program ma wyliczyć realny dochód (po odliczeniu podatków, pomijamy ZUS) oraz jakie wartości podatków ma do zapłacenia. Nasze wymagania:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Feature: computing real income and taxes

  Scenario: create company and sell something
    Given I own company called 'Flomedia.pl' with income tax rate 19
    When I sell something for 12300 with vat rate 23
    Then my real income will be 8100.00

  Scenario: create company and sell/buy something
    Given I own company called 'Flomedia.pl' with income tax rate 19
    When I sell something for 12300 with vat rate 23
    And I buy Mobile for 1500 with vat rate 23
    Then my real income will be 8612.20

  Scenario: create company and sell: 1 item, buy: 2 items
    Given I own company called 'Flomedia.pl' with income tax rate 19
    When I sell something for 25000 with vat rate 23
    And I buy Computer for 11500 with vat rate 23
    And I buy Notebook for 2900 with vat rate 23
    Then my real income will be 21380.49

  Scenario: create company and sell or buy a lot of stuff
    Given I own company called 'Flomedia.pl' with income tax rate 19
    When I sell services for 25000 with vat rate 23
    And I buy Computer for 11500 with vat rate 23
    And I buy Notebook for 2900 with vat rate 23
    And I buy Book for 59.99 with vat rate 5
    And I sell services for 5000 with vat rate 23
    Then my real income will be 24686.89

  Scenario: create company and sell or buy a lot of stuff and compute taxes
    Given I own company called 'Flomedia.pl' with income tax rate 19
    When I sell services for 25000 with vat rate 23
    And I buy Computer for 11500 with vat rate 23
    And I buy Notebook for 2900 with vat rate 23
    And I buy Book for 59.99 with vat rate 5
    And I sell services for 5000 with vat rate 23
    Then vat will be equal 2914.21 and income tax 2398.90

  Scenario: create company and buy something and have negative vat tax
    Given I own company called 'Flomedia.pl' with income tax rate 19
    When I buy Something for 300 with vat rate 23
    Then vat will be equal -56.10 and income tax -46.34

zapisujemy do folderze „features” pod nazwą „companies.features” a w folderze steps tworzymy plik „companies.py”. Dodam tylko, że krok „AND” po prostu kontynuuje krok wcześniejszy.


Czekaj, stop!

Podoba Ci się to co tworzę? Jeśli tak to zapraszam Cię do zapisania się na newsletter:
a w ramach prezentu otrzymasz całkowicie za darmo, dwa dokumenty PDF „6 (nie zawsze oczywistych) błędów popełnianych podczas nauki programowania” który jest jednym z efektów ponad siedmioletniej pracy i obserwacji rozwoju niejednego programisty oraz „Wstęp do testowania w Pythonie”, będący wprowadzeniem do biblioteki PyTest.
Jeśli to Cię interesuje to zapraszam również na swoje social media.

Jak i do ewentualnego postawienia mi kawy :)
Postaw mi kawę na buycoffee.to

A tak wygląda nasz kod, który będzie realizował ową funkcjonalność:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from dataclasses import dataclass, field
from functools import partial
from product import Product

without_init = partial(field, init=False, default=0)


@dataclass
class Company:
    name: str
    income_tax_rate: int
    vat_to_pay: float = without_init()
    income_tax_to_pay: float = without_init()
    income: float = without_init()

    def sell(self, product: Product) -> 'Company':
        self.income += product.price
        self.vat_to_pay += round((product.price * product.vat_rate) / (100 + product.vat_rate), 2)
        self.income_tax_to_pay += (self.income_tax_rate * (product.price - product.vat_value)) / 100
        return self

    def buy(self, product: Product) -> 'Company':
        vat_of_product = product.vat_value
        self.vat_to_pay -= vat_of_product
        self.income_tax_to_pay -= (self.income_tax_rate * (product.price - vat_of_product)) / 100
        return self

    def get_taxes(self):
        return round(self.vat_to_pay, 2), round(self.income_tax_to_pay, 2)

    def get_real_income(self):
        return round(self.income - self.income_tax_to_pay - self.vat_to_pay, 2)

i tutaj również mamy dekorator dataclass. To, co jest tu ciekawego to to, że dla pól:

  • vat_to_pay
  • income_tax_to_pay
  • income

zakładamy domyślną wartość równą 0 i nie ma potrzeby, by pojawiały się one w inicie, więc korzystając z funkcji partial zrobiłem alias do funkcji field, dostając w rezultacie funkcję without_init, której użyłem do zdefiniowania zachowania tych pól.

Reszta kodu jest prosta – możemy kupować i sprzedawać, co modyfikuje pola odpowiadające za to ile VATu i podatku dochodowego mamy zapłacić i te pola zwracamy w funkcji get_taxes, natomiast realny dochód zwraca funkcja get_real_income. Ogólnie bajecznie proste. Matematyka też nie powinna Was pokonać :)

Skoro to mamy, to czas na kod pozwalający ożywić nasze wymagania:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from behave import *
from company import Company, Product


@given("I own company called '{name}' with income tax rate {income_tax_rate}")
def step_impl(context, name, income_tax_rate):
    context.company = Company(name, int(income_tax_rate))


@when("I sell {name} for {value} with vat rate {vat_rate}")
def step_impl(context, name, value, vat_rate):
    context.company.sell(Product(name, float(value), int(vat_rate)))


@then("my real income will be {income}")
def step_impl(context, income):
    assert context.company.get_real_income() == float(income)


@then("vat will be equal {vat_value} and income tax {income_tax_value}")
def step_impl(context, vat_value, income_tax_value):
    assert context.company.get_taxes() == (float(vat_value), float(income_tax_value))


@when("I buy {name} for {price} with vat rate {vat_rate}")
def step_impl(context, name, price, vat_rate):
    product = Product(name, float(price), int(vat_rate))
    context.company.buy(product)

Przeanalizuj sobie go na spokojnie, nie ma tam nic czego nie opisałem wcześniej.

I rezultat:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Feature: computing real income and taxes # features/companies.feature:1

  Scenario: create company and sell something                        # features/companies.feature:3
    Given I own company called 'Flomedia.pl' with income tax rate 19 # features/steps/companies.py:5 0.000s
    When I sell something for 12300 with vat rate 23                 # features/steps/companies.py:10 0.000s
    Then my real income will be 8100.00                              # features/steps/companies.py:15 0.000s

  Scenario: create company and sell/buy something                    # features/companies.feature:8
    Given I own company called 'Flomedia.pl' with income tax rate 19 # features/steps/companies.py:5 0.000s
    When I sell something for 12300 with vat rate 23                 # features/steps/companies.py:10 0.000s
    And I buy Mobile for 1500 with vat rate 23                       # features/steps/companies.py:28 0.000s
    Then my real income will be 8612.20                              # features/steps/companies.py:15 0.000s

  Scenario: create company and sell: 1 item, buy: 2 items            # features/companies.feature:14
    Given I own company called 'Flomedia.pl' with income tax rate 19 # features/steps/companies.py:5 0.000s
    When I sell something for 25000 with vat rate 23                 # features/steps/companies.py:10 0.000s
    And I buy Computer for 11500 with vat rate 23                    # features/steps/companies.py:28 0.000s
    And I buy Notebook for 2900 with vat rate 23                     # features/steps/companies.py:28 0.000s
    Then my real income will be 21380.49                             # features/steps/companies.py:15 0.000s

  Scenario: create company and sell or buy a lot of stuff            # features/companies.feature:21
    Given I own company called 'Flomedia.pl' with income tax rate 19 # features/steps/companies.py:5 0.000s
    When I sell services for 25000 with vat rate 23                  # features/steps/companies.py:10 0.000s
    And I buy Computer for 11500 with vat rate 23                    # features/steps/companies.py:28 0.000s
    And I buy Notebook for 2900 with vat rate 23                     # features/steps/companies.py:28 0.000s
    And I buy Book for 59.99 with vat rate 5                         # features/steps/companies.py:28 0.000s
    And I sell services for 5000 with vat rate 23                    # features/steps/companies.py:10 0.000s
    Then my real income will be 24686.89                             # features/steps/companies.py:15 0.000s

  Scenario: create company and sell or buy a lot of stuff and compute taxes  # features/companies.feature:30
    Given I own company called 'Flomedia.pl' with income tax rate 19         # features/steps/companies.py:5 0.000s
    When I sell services for 25000 with vat rate 23                          # features/steps/companies.py:10 0.000s
    And I buy Computer for 11500 with vat rate 23                            # features/steps/companies.py:28 0.000s
    And I buy Notebook for 2900 with vat rate 23                             # features/steps/companies.py:28 0.000s
    And I buy Book for 59.99 with vat rate 5                                 # features/steps/companies.py:28 0.000s
    And I sell services for 5000 with vat rate 23                            # features/steps/companies.py:10 0.000s
    Then vat will be equal 2914.21 and income tax 2398.90                    # features/steps/companies.py:22 0.000s

  Scenario: create company and buy something and have negative vat tax  # features/companies.feature:39
    Given I own company called 'Flomedia.pl' with income tax rate 19    # features/steps/companies.py:5 0.000s
    When I buy Something for 300 with vat rate 23                       # features/steps/companies.py:28 0.000s
    Then vat will be equal -56.10 and income tax -46.34                 # features/steps/companies.py:22 0.000s

1 feature passed, 0 failed, 0 skipped
6 scenarios passed, 0 failed, 0 skipped
29 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.002s

a więc.. Działa!

Wisienka na torcie

Mamy program, który oczywiście może zostać użyty np. w taki sposób:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from product import Product
from company import Company


company = Company("Flomedia.pl", 19)

services = Product("Usługi", 11500, 23)
computer = Product("Komputer", 5000, 23)
mobile = Product("IPhone", 9000, 23)
some_other_services = Product("Inne usługi", 4500, 23)

taxes = company.\
    sell(services).\
    buy(computer).\
    buy(mobile).\
    sell(some_other_services).get_taxes()

print(taxes)

ten myk, że zwracam „self”, to chyba jest jakiś wzorzec, ale… Nie pamiętam jaki. Tak czy siak, program działa, testy mamy, więc sprawdźmy pokrycie, czyli pierw:

1
coverage run --source="." --omit="*/venv/*" -m behave

następnie:

1
coverage report product.py company.py

i otrzymamy

1
2
3
4
5
6
Name         Stmts   Miss  Cover
--------------------------------
company.py      24      0   100%
product.py       8      0   100%
--------------------------------
TOTAL           32      0   100%

Druga wisienka na torcie

Mamy testy, mamy coverage to jeszcze… Niech to się samo testuje!

Użyjmy Travisa, dodajmy plik „.travis.yml” o zawrotnie skomplikowanej zawartości:

1
2
3
4
5
6
language: python
python: "3.8"
install:
    - pip install -r requirements.txt
script:
    - behave

A nasz podłączony pod repozytorium Travis zaświeci się na zielono:

Zmierzając do brzegu

Behave to fajne narzędzie, bo sprawia że Twoje testy są po prostu łatwiejsze w czytaniu. I nawet jeśli nie masz w firmie klienta, który rozumie proces wytwarzania oprogramowania a co za tym idzie – będzie dostarczać specyfikacje pisaną w taki sposób, to wartość płynąca z wcześniej wspomnianej czytelności, jest nie do podważenia. Kod jest dostępny na GitHubie, link zostawiam tutaj – https://github.com/mmazurekdev/behave-python-tests

Dzięki za wizytę,
Mateusz Mazurek

A może wolisz nowości na mail?

Subskrybuj
Powiadom o
guest

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.

9 komentarzy
Inline Feedbacks
View all comments
Maciej Michalec

Fajnie, że poruszyłeś temat Gherkina w Pythonie, + za ciekawe przykłady. ;) Sam na taki sposób pisania testów natknąłem się kilka miesięcy temu, choć akurat w moim przypadku używaliśmy TypeScripta. Kilka swoich spostrzeżeń związanych z tym doświadczeniem zamieściłem na https://polydev.pl/2019/09/testy-frontendu-okiem-poczatkujacego/

Mateusz Rus

Bardzo ciekawy temat, aktualnie jestem na bieżąco z podobnymi tematami :)

Pozdrawiam, Mateusz!

Szymon Przedwojski

Dzięki za ciekawy artykuł! Chętnie wypróbuję behave niedługo w jakimś projekcie.
Przypomniały mi się czasy, kiedy testy do Javy pisałem w Spocku, podobnie to wyglądało :)

Łukasz

Pierwsze słyszę o tym, że taki sposób testowania można zautomatyzować. Fajna sprawa :)

Radek

Mam pytanie po co umieszczać w konstruktorze vat_value a następnie wyklucznie je field(init=false) co rozumiem ze nie jest wymagane w wywołaniu a nastepnie podanie go w metodzie dodającej wartośc jego w konstruktorze ? Nie można było bezpośrednio przypisać metody do obiektu konstruktora? ;)