I719 Fundamentals of Python/testing

From ICO wiki

Basic Testing

In the examples, app.py, test_app.py and __init__.py are all in the same directory. The tests are ran with the command python3 -m unittest test_app.py

check the output of a function

app.py

def get_hello_world():
    return "Hello World!"

test_app.py

import unittest

from app import get_hello_world

class HelloWorldTestCase(unittest.TestCase):
    def test_value(self):
        result = get_hello_world()
        self.assertEqual("Hello World!", result)

Check the side effect of a function

app.py

class MyPrinter():
    def __init__(self, value=None):
        self.value = value

    def print_value(self):
        print(self.value)

    def set_value(self, value):
        self.value = value

test_app.py

import unittest

from app import MyPrinter

class HelloWorldTestCase(unittest.TestCase):
    def test_value(self):
        value = "Test Value"
        my_printer = MyPrinter()
        my_printer.set_value(value)
        self.assertEqual(my_printer.value, value)

Advanced Testing

Testing the bitcoin price printer

You can convert

btc_price.py

import requests

def main():
    r = requests.get('https://api.coindesk.com/v1/bpi/currentprice.json')
    data = r.json()
    price = data['bpi']['EUR']['rate']
    print(price)

if __name__ == '__main__':
    main()

to something with a testable function

btc_price.py

import requests

def get_btc_price():
    r = requests.get('https://api.coindesk.com/v1/bpi/currentprice.json')
    data = r.json()
    price = data['bpi']['EUR']['rate']
    return price

if __name__ == '__main__':
    print(get_btc_price())

Writing a Unit test

in the same directory, make a file that starts with test_. So for this example, the file is named test_btc_price.py. Also ensure an __init__.py file exists so you can import python modules in this directory.

├── btc_price.py
├── test_btc_price.py
├── __init__.py

`
and then write a unit test on the value

test_btc_price.py

import unittest

from btc_price import get_btc_price

class BtcPriceTestCase(unittest.TestCase):
    def test_price_accurate(self):
        price = get_btc_price() # The bitcoin returned changes over time!
        self.assertEqual(price, '1000')

and run using python3 -m unittest btc_price_test.py

This test will fail becuase the price of bitcoins change.
so instead check that value is correct in another way.
In this example, we check that the string value is floating point, like 1,101.12. This is not as robust as checking if the value is correct. So if the value does not change, do not do this.

btc_price_test.py

import unittest

from btc_price import get_btc_price

class BtcPriceTestCase(unittest.TestCase):
    def test_price_format(self):
        """Test price is a string with correct format"""
        price = get_btc_price() # The bitcoin returned changes over time!
        price_without_comma = price.replace(',', '')
        # if string `price_without_comma` cannot be converted to
        # a float, then this line will raise an error.
        # an unhandled error will cause the test to fail.
        float(price_without_comma)

Testing IO dependent functions without doing the IO

In the previous Bitcoin price example, running the test also makes a HTTP request.

This means:

  • our test will fail without internet
  • our test will fail if the bitcoin price API changes or is down

You may want to test these things in your test. This would make our unittest more like a functional test.

You can stub out IO functions, and test if the function is correctly written, not that the price API works.

To do this with depedency inject, we must change our original code as well.

btc_price.py

import requests

# use `requests` by default, but allow another library to be used
def get_btc_price(request_library=requests):
    r = request_library.get('https://api.coindesk.com/v1/bpi/currentprice.json')
    data = r.json()
    price = data['bpi']['EUR']['rate']
    return price

if __name__ == '__main__':
    print(get_btc_price())
import json
import requests
import unittest

MOCK_RESPONSE_BODY ='''
{"time":{"updated":"Mar 4, 2017 11:00:00 UTC","updatedISO":"2017-03-04T11:00:00+00:00","updateduk":"Mar 4, 2017 at 11:00 GMT"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","bpi":{"USD":{"code":"USD","symbol":"$","rate":"1,284.8250","description":"United States Dollar","rate_float":1284.825},"GBP":{"code":"GBP","symbol":"£","rate":"1,044.8261","description":"British Pound Sterling","rate_float":1044.8261},"EUR":{"code":"EUR","symbol":"€","rate":"1,209.5317","description":"Euro","rate_float":1209.5317}}}
'''

class MockResponse:
    @staticmethod
    def json():
        return json.loads(MOCK_RESPONSE_BODY)

class MockRequests:
    @staticmethod
    def get(url):
        # This will raise an `AssertionError` if the url that the function is called with is different than expected, causing the test to fail

        assert url == 'https://api.coindesk.com/v1/bpi/currentprice.json'
        return MockResponse()

class BtcPriceTestCase(unittest.TestCase):
    def test_price_accurate(self):
        # use the stub version for the test
        price = get_btc_price(request_library=MockRequests())
        # The value does not change now
        self.assertEqual(price, '1,209.5317')

if __name__ == '__main__':
    print(get_btc_price())

That was a lot of code. Forturnately python 3 has a library for making these mock objects. See https://docs.python.org/3/library/unittest.mock.html

test_btc_price.py

import json
import unittest

from unittest.mock import Mock

MOCK_RESPONSE_BODY = '''
{"time":{"updated":"Mar 4, 2017 11:00:00 UTC","updatedISO":"2017-03-04T11:00:00+00:00","updateduk":"Mar 4, 2017 at 11:00 GMT"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","bpi":{"USD":{"code":"USD","symbol":"$","rate":"1,284.8250","description":"United States Dollar","rate_float":1284.825},"GBP":{"code":"GBP","symbol":"£","rate":"1,044.8261","description":"British Pound Sterling","rate_float":1044.8261},"EUR":{"code":"EUR","symbol":"€","rate":"1,209.5317","description":"Euro","rate_float":1209.5317}}}
'''


class BtcPriceTestCase(unittest.TestCase):
    def test_price_accurate(self):
        # generate the mocks in the test function scope
        mock_response = Mock()
        mock_response.json = Mock(return_value=json.loads(MOCK_RESPONSE_BODY))

        mock_requests = Mock()
        mock_requests.get = Mock(return_value=mock_response)
        price = get_btc_price(request_library=mock_requests)
        # The value does not change now
        self.assertEqual(price, '1,209.5317')

Patch

But we still had to change our function to support a changeable 'requests_library'. This is okay as well, and is normal in some languages. The practice is called Dependency Injection, and is used with object oriented design. But often in python, people will 'patch' a function when it is ran in a testing environment. This changes the body of the function in runtime.

see https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch

test_btc_price.py

import json
import unittest
from unittest.mock import Mock, patch

from btc_price import get_btc_price

MOCK_RESPONSE_BODY = '''
{"time":{"updated":"Mar 4, 2017 11:00:00 UTC","updatedISO":"2017-03-04T11:00:00+00:00","updateduk":"Mar 4, 2017 at 11:00 GMT"},"disclaimer":"This data was produced from the CoinDesk Bitcoin Price Index (USD). Non-USD currency data converted using hourly conversion rate from openexchangerates.org","bpi":{"USD":{"code":"USD","symbol":"$","rate":"1,284.8250","description":"United States Dollar","rate_float":1284.825},"GBP":{"code":"GBP","symbol":"£","rate":"1,044.8261","description":"British Pound Sterling","rate_float":1044.8261},"EUR":{"code":"EUR","symbol":"€","rate":"1,209.5317","description":"Euro","rate_float":1209.5317}}}
'''

class BtcPriceTestCase(unittest.TestCase):
    def test_price_accurate(self):
        # generate the mocks in the test function scope
        mock_response = Mock()
        mock_response.json = Mock(return_value=json.loads(MOCK_RESPONSE_BODY))

        mock_requests = Mock()
        mock_requests.get = Mock(return_value=mock_response)
        with patch('btc_price.requests', mock_requests):
            price = get_btc_price()
        # The value does not change now
        self.assertEqual(price, '1,209.5317')

And you can omit the keyword argument for request_library, reverting to the original script
btc_price.py

import requests


def get_btc_price():
    r = requests.get('https://api.coindesk.com/v1/bpi/currentprice.json')
    data = r.json()
    price = data['bpi']['EUR']['rate']
    return price

if __name__ == '__main__':
    print(get_btc_price())

check the print value

import unittest
from unittest.mock import Mock, patch


def print_hello():
    print('Hello')

    
class PrintHelloTestCase(unittest.TestCase):
    @patch("builtins.print",autospec=True)
    def test_somethingelse(self, mock_print):
        print_hello()
        mock_print.assert_called_with("Hello")
    
if __name__ == '__main__':
    print_hello()