Programowanie oparte o testy

Załóżmy że mamy napisać kod wykonujący jakąś czynność. Zamiast zabrać się od razu za pisanie kodu zaczniemy od testów jednostkowych - obrazując TDD (Test Driven Development), czyli metodę pisania kodu w oparciu o krótkie testy sprawdzające poprawność kodu z założeniami. W punktach można przedstawić to tak:
  • piszemy test
  • "patrzymy" jak się nie powodzi
  • piszemy kod by test się wykonał
  • piszemy kod by test udał się
  • porządkujemy kod i sprawdzamy testami
  • porządkujemy testy

Dzięki temu tworzymy API, które jest od razu używane i wiemy w jakim jest stanie (czy działa, czy nie, jak szybko itd.). Dodatkowo nakładając siatkę małych testów nie zaczniemy tworzyć ogromnych bloków kodu trudnych do utrzymania i rozbudowy. W Pytonie znajdziemy wbudowany unittest, a także inne implementacje jak nose, nosy, pymock, py.test. Przejdźmy więc do przykładu.

A więc chcemy napisać klasę, która np. będzie mogła przeliczać kwoty podane w Euro, czy funtach na złotówki. Zaczynamy od testu, tworzymy plik tests.py o kodzie:
# -*- coding: utf-8 -*-
import unittest

class TestCurrencyCalculator(unittest.TestCase):
  
  def testCurrencyCalculatorExist(self):
    calculator = CurrencyCalculator()


if __name__=="__main__":
  unittest.main()
Wykonując plik otrzymamy oczywiście wyjątek:
======================================================================
ERROR: testCurrencyCalculatorExist (__main__.TestCurrencyCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 7, in testCurrencyCalculatorExist
    calculator = CurrencyCalculator()
NameError: global name 'CurrencyCalculator' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)
nie mamy nigdzie zdefiniowanej klasy CurrencyCalculator, co jest oczywiste. Dodajemy więc import:
# -*- coding: utf-8 -*-
import unittest
from calculator import CurrencyCalculator

class TestCurrencyCalculator(unittest.TestCase):
  
  def testCurrencyCalculatorExist(self):
    calculator = CurrencyCalculator()


if __name__=="__main__":
  unittest.main()
Co skończy się błędem przy imporcie nieistniejącego modułu
Traceback (most recent call last):
  File "tests.py", line 3, in 
    from calculator import CurrencyCalculator
ImportError: No module named calculator
Więc tworzymy plik calculator.py. Test nadal się nie wykona:
Traceback (most recent call last):
  File "tests.py", line 3, in 
    from calculator import CurrencyCalculator
ImportError: cannot import name CurrencyCalculator
No to tworzymy w calculator.py klasę CurrencyCalculator:
class CurrencyCalculator:
    pass
Test powiedzie się:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
Mamy pierwszy gotowy test, który przechodzi. W teście założyliśmy że klasa o podanej nazwie "CurrencyCalculator" ma istnieć i jak najprostszymi etapami dotarliśmy do momentu, w którym test się powiódł. Teraz nasza klasa musi mieć jakąś funkcjonalność potrzebną do przeliczania podanych kwot - musi mieć wartości kursowe euro i funta. Opiszmy to testem:
class TestCurrencyCalculator(unittest.TestCase):
  
  def testCurrencyCalculatorExist(self):
    calculator = CurrencyCalculator()
  
  def testCurrencyCalculatorHasEuroExchangeRate(self):
    calculator = CurrencyCalculator()
    assert calculator.euro_exchange_rate != None, "calculator euro_exchange_rate is None"
  
  def testCurrencyCalculatorHasPoundExchangeRate(self):
    calculator = CurrencyCalculator()
    assert calculator.pound_exchange_rate != None, "calculator pound_exchange_rate is None"
Oba testy nie przechodzą, bo nie ma takich atrybutów:
AttributeError: CurrencyCalculator instance has no attribute 'euro_exchange_rate'
Więc dodajemy je do klasy, powiedzmy że napiszemy taki kod:
class CurrencyCalculator:
    def __init__(self):
        euro_exchange_rate = 0
        pound_exchange_rate = 0
Odpalamy testy i nadal widzimy że oba testy nie przechodzą z tym samym błędem - patrzymy na kod funkcji i od razu widzimy drobny błąd zapisu atrybutów - brak "self.":
class CurrencyCalculator:
    def __init__(self):
        self.euro_exchange_rate = 0
        self.pound_exchange_rate = 0
I testy przechodzą. Teraz wypadałoby dodać metody do ustawiania kursów obu walut. Tworzymy kolejne testy:
def testCurrencyCalculatorSetEuroExchangeRate(self):
    calculator = CurrencyCalculator()
    calculator.setEuroExchangeRate(4.1)
    assert calculator.euro_exchange_rate == 4.1, "calculator euro_exchange_rate did not set correctly"
  
def testCurrencyCalculatorSetEuroExchangeRate(self):
    calculator = CurrencyCalculator()
    calculator.setPoundExchangeRate(4.6)
    assert calculator.pound_exchange_rate == 4.6, "calculator pound_exchange_rate did not set correctly"
Oczywiście testy się nie wykonają bo metody te nie istnieją:
AttributeError: CurrencyCalculator instance has no attribute 'setPoundExchangeRate'
Dodajemy metodę do klasy:
def setPoundExchangeRate(self, value):
        pass
Test się wykonuje, lecz nie przechodzi bo metoda nie ustawia wartości:
AssertionError: calculator pound_exchange_rate did not set correctly
Dopisujemy potrzebny kod i gotowe:
class CurrencyCalculator:
    def __init__(self):
        self.euro_exchange_rate = 0
        self.pound_exchange_rate = 0
    
    def setEuroExchangeRate(self, value):
        self.euro_exchange_rate = value
    
    def setPoundExchangeRate(self, value):
        self.pound_exchange_rate = value
Testy przechodzą i my mamy zaimplementowaną zakładaną funkcjonalność. Teraz można uporządkować nieco testy. Nie potrzebujemy testu "startowego" - testCurrencyCalculatorExist, jak i możemy wykorzystać metodę setUp unittestów do stworzenia jednego obiektu testowanej klasy zamiast robić to w każdej metodzie. Oto testy po wprowadzeniu zmian:
# -*- coding: utf-8 -*-
import unittest
from calculator import CurrencyCalculator

class TestCurrencyCalculator(unittest.TestCase):
  
  def setUp(self):
    self.calculator = CurrencyCalculator()

  def testCurrencyCalculatorHasEuroExchangeRate(self):
    assert self.calculator.euro_exchange_rate != None, "calculator euro_exchange_rate is None"
  
  def testCurrencyCalculatorHasPoundExchangeRate(self):
    assert self.calculator.pound_exchange_rate != None, "calculator pound_exchange_rate is None"

  def testCurrencyCalculatorSetEuroExchangeRate(self):
    self.calculator.setEuroExchangeRate(4.1)
    assert self.calculator.euro_exchange_rate == 4.1, "calculator euro_exchange_rate did not set correctly"
  
  def testCurrencyCalculatorSetEuroExchangeRate(self):
    self.calculator.setPoundExchangeRate(4.6)
    assert self.calculator.pound_exchange_rate == 4.6, "calculator pound_exchange_rate did not set correctly"


if __name__=="__main__":
  unittest.main()
Po porządkach sprawdzamy testy czy wszystkie nadal kończą się powodzeniem. Brakuje nam jeszcze głównej funkcjonalności - podawania kwoty i przeliczania jej na PLN. Piszemy więc testy:
def testCurrencyCalculatorPoundsToPLN(self):
    self.calculator.setPoundExchangeRate(4.6)
    assert self.calculator.poundsToPLN(1) == 4.6, "calculator poundsToPLN failed"
    assert self.calculator.poundsToPLN(2) == 9.2, "calculator poundsToPLN failed"
  
def testCurrencyCalculatorEurosToPLN(self):
    self.calculator.setEuroExchangeRate(4.1)
    assert self.calculator.eurosToPLN(1) == 4.1, "calculator eurosToPLN failed"
    assert self.calculator.eurosToPLN(2) == 8.2, "calculator eurosToPLN failed"
I żeby testy uszczęśliwić piszemy następnie metody klasy:
def eurosToPLN(self, ammount):
        return self.euro_exchange_rate*ammount
    
def poundsToPLN(self, ammount):
        return self.pound_exchange_rate*ammount
Testy przechodzą, a my mamy gotową klasę.

Powyższy prosty przykład stanowi wprowadzenie do TDD i nie wyczerpuje tematu, jak i wszystkich metod pisania testów. Zainteresowanym polecam m.in. prezentację Test Driven Development in Python.

RkBlog

Podstawy Pythona, 14 February 2010

Comment article
Comment article RkBlog main page Search RSS Contact