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:Jeśli to Cię interesuje to zapraszam również na swoje social media.
Jak i do ewentualnego postawienia mi kawy :)
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
Mateusz Mazurek
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/
Dzięki za pozytywne słowa i link:) na pewno rozwinie on spojrzenie na ten temat:)
Bardzo ciekawy temat, aktualnie jestem na bieżąco z podobnymi tematami :)
Pozdrawiam, Mateusz!
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 :)
Nie mam pojęcia czym jest Spock, nie dotrwałem do poznania:) ale kiedyś wydaje mi się ze na jakiejś konferencji ktoś wychwalal:) a co do behave – to baw się dobrze:)
Pierwsze słyszę o tym, że taki sposób testowania można zautomatyzować. Fajna sprawa :)
Co nie?!:) Też uważam że jest super!
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? ;)
A mógłbyś napisać kawałek kodu? Ciężko tak na sucho:D