8.7. Function Doctest

8.7.1. Rationale

8.7.2. Syntax

  • Docstring is a first multiline comment in: File/Module, Class, Method/Function

  • Used for generating help() documentation

  • It is accessible in __doc__ property of an object

  • Used for doctest

  • PEP 257 Docstring convention - For multiline always use three double quote (""") characters

  • More information in Function Doctest

Listing 8.19. Docstring used for doctest
def apollo_dsky(noun, verb):
    """
    This is the Apollo Display Keyboard
    It takes noun and verb

    >>> apollo_dsky(6, 61)
    Program selected. Noun: 06, verb: 61

    >>> apollo_dsky(16, 68)
    Program selected. Noun: 16, verb: 68
    """
    print(f'Program selected. Noun: {noun:02}, verb: {verb:02}')

8.7.3. Running Tests

  1. Note, that doctests are not discovered in scratch files in PyCharm

  2. Running tests in Pycharm IDE (either option):

    • Right click on source code with doctests -> Run 'Doctest for ...'

    • View menu -> Run... -> Doctest in my_function

  3. Running Tests from Python Code:

    if __name__ == "__main__":
        import doctest
        doctest.testmod()
    
  4. Running tests from command line:

    Listing 8.20. Display only errors. With -v display progress
    $ python -m doctest example.py
    $ python -m doctest -v example.py
    

8.7.4. Test Int, Float

Listing 8.21. int values
def add_numbers(a, b):
    """
    >>> add_numbers(1, 2)
    3
    >>> add_numbers(-1, 1)
    0
    >>> add_numbers(0, 0)
    0
    """
    return a + b
Listing 8.22. float values
def add_numbers(a, b):
    """
    >>> add_numbers(2.5, 1.2)
    3.7

    >>> add_numbers(0.1, 0.2)
    0.30000000000000004

    >>> add_numbers(0.1, 0.2)  # doctest: +ELLIPSIS
    0.3000...
    """
    return a + b

8.7.5. Test Bool

AGE_ADULT = 18

def is_adult(age):
    """
    Function checks if person is adult.
    Adult person is over 18 years old.

    >>> is_adult(18)
    True

    >>> is_adult(17.9)
    False
    """
    if age >= AGE_ADULT:
        return True
    else:
        return False

8.7.6. Test Str

  • Python will change to single quotes in most cases

  • Python will change to double quotes to avoid escapes

  • print() function output, don't have quotes

Listing 8.23. Returning str. Python will change to single quotes in most cases
def echo(text):
    """
    >>> echo('hello')
    'hello'

    # Python will change to single quotes in most cases
    >>> echo("hello")
    'hello'

    Following test will fail
    >>> echo('hello')
    "hello"

    Python will change to double quotes to avoid escapes
    >>> echo('It\\'s Twardowski\\'s Moon')
    "It's Twardowski's Moon"
    """
    return text
Listing 8.24. There are no quotes in print() function output
def echo(text):
    """
    >>> echo('hello')
    hello
    """
    print(text)
Listing 8.25. Testing print(str) with newlines
def echo(text):
    """
    >>> echo('hello')
    hello
    hello
    hello
    <BLANKLINE>
    """
    print(f'{text}\n' * 3)

8.7.7. Test Sequences

Listing 8.26. Test Sequences
def celsius_to_kelvin(sequence):
    """
    >>> celsius_to_kelvin([1,2,3])
    [274.15, 275.15, 276.15]

    >>> celsius_to_kelvin((1,2,3))
    [274.15, 275.15, 276.15]

    >>> celsius_to_kelvin({1,2,3})
    [274.15, 275.15, 276.15]

    >>> celsius_to_kelvin(frozenset({1,2,3}))
    [274.15, 275.15, 276.15]
    """
    return [x + 273.15 for x in sequence]
Listing 8.27. Test Sequences
def celsius_to_kelvin(sequence):
    """
    >>> celsius_to_kelvin([1,2,3])
    [274.15, 275.15, 276.15]

    >>> celsius_to_kelvin((1,2,3))
    (274.15, 275.15, 276.15)

    >>> celsius_to_kelvin({1,2,3})
    {274.15, 275.15, 276.15}

    >>> celsius_to_kelvin(frozenset({1,2,3}))
    frozenset({274.15, 275.15, 276.15})
    """
    cls = type(sequence)
    return cls(x + 273.15 for x in sequence)

8.7.8. Test Exceptions

Listing 8.28. Testing for exceptions
def add_numbers(a, b):
    """
    >>> add_numbers('one', 'two')
    Traceback (most recent call last):
        ...
    TypeError: Argument must be int or float
    """
    if not isinstance(a, (int, float)):
        raise TypeError('Argument must be int or float')

    if not isinstance(b, (int, float)):
        raise TypeError('Argument must be int or float')

    return a + b
Listing 8.29. This test will fail. Expected exception, got 2.0
def add_numbers(a, b):
    """
    >>> add_numbers(True, 1)
    Traceback (most recent call last):
        ...
    ValueError: not a number
    """
    if not isinstance(a, (int, float)):
        raise ValueError('not a number')

    if not isinstance(b, (int, float)):
        raise ValueError('not a number')

    return a + b

# Expected:
#     Traceback (most recent call last):
#         ...
#     ValueError: not a number
# Got:
#     2.0
Listing 8.30. This test will pass.
def add_numbers(a, b):
    """
    >>> add_numbers(True, 1)
    Traceback (most recent call last):
        ...
    ValueError: not a number
    """
    if type(a) not in (int, float):
        raise ValueError('not a number')

    if type(b) not in (int, float):
        raise ValueError('not a number')

    return a + b

8.7.9. Test Python Expressions

Listing 8.31. Using python statements in doctest
def when(date):
    """
    >>> from datetime import datetime, timezone
    >>> moon = datetime(1969, 7, 21, 17, 54, tzinfo=timezone.utc)
    >>> when(moon)
    1969-07-21 17:54 UTC
    """
    print(f'{date:%Y-%m-%d %H:%M %Z}')

8.7.10. Examples

Listing 8.32. Adding two numbers
def add_numbers(a, b):
    """
    >>> add_numbers(1, 2)
    3.0
    >>> add_numbers(-1, 1)
    0.0
    >>> add_numbers(0.1, 0.2)  # doctest: +ELLIPSIS
    0.3000...
    >>> add_numbers(1.5, 2.5)
    4.0
    >>> add_numbers(1, 1.5)
    2.5
    >>> add_numbers([1, 2], 3)
    Traceback (most recent call last):
        ...
    ValueError: not a number
    >>> add_numbers(0, [1, 2])
    Traceback (most recent call last):
        ...
    ValueError: not a number
    >>> add_numbers('one', 'two')
    Traceback (most recent call last):
        ...
    ValueError: not a number
    >>> add_numbers(True, 1)
    Traceback (most recent call last):
        ...
    ValueError: not a number
    """
    if type(a) not in (int, float):
        raise ValueError('not a number')

    if type(b) not in (int, float):
        raise ValueError('not a number')

    return float(a + b)
Listing 8.33. Celsius to Kelvin temperature conversion
def celsius_to_kelvin(celsius):
    """
    >>> celsius_to_kelvin(0)
    273.15

    >>> celsius_to_kelvin(1)
    274.15

    >>> celsius_to_kelvin(-1)
    272.15

    >>> celsius_to_kelvin(-273.15)
    0.0

    >>> celsius_to_kelvin(-273.16)
    Traceback (most recent call last):
        ...
    ValueError: Negative Kelvin

    >>> celsius_to_kelvin(-300)
    Traceback (most recent call last):
        ...
    ValueError: Negative Kelvin

    >>> celsius_to_kelvin(True)
    Traceback (most recent call last):
        ...
    TypeError: Argument must be: int, float or Sequence[int, float]

    >>> celsius_to_kelvin([0, 1, 2, 3])
    [273.15, 274.15, 275.15, 276.15]

    >>> celsius_to_kelvin({0, 1, 2, 3})
    {273.15, 274.15, 275.15, 276.15}

    >>> celsius_to_kelvin([0, 1, 2, -300])
    Traceback (most recent call last):
        ...
    ValueError: Negative Kelvin

    >>> celsius_to_kelvin([0, 1, [2, 3], 3])
    [273.15, 274.15, [275.15, 276.15], 276.15]
    """
    datatype = type(celsius)

    if type(celsius) in {list, tuple, set, frozenset}:
        return datatype(celsius_to_kelvin(x) for x in celsius)

    if datatype not in {int, float}:
        raise TypeError('Argument must be: int, float or Sequence[int, float]')

    kelvin = celsius + 273.15

    if kelvin < 0.0:
        raise ValueError('Negative Kelvin')

    return float(kelvin)

8.7.11. Assignments

8.7.11.1. Refactoring

English
  1. Use data from "Input" section (see below)

  2. Write implementation of a function celsius_to_kelvin

  3. All tests must pass

Polish
  1. Użyj danych z sekcji "Input" (patrz poniżej)

  2. Napisz implementację funkcji celsius_to_kelvin

  3. Wszystkie testy muszą przechodzić

Input
def celsius_to_kelvin(degrees):
    """
    >>> celsius_to_kelvin(0)
    273.15
    >>> celsius_to_kelvin(1)
    274.15
    >>> celsius_to_kelvin(-1)
    272.15
    >>> celsius_to_kelvin('a')
    Traceback (most recent call last):
        ...
    TypeError: Invalid argument
    >>> celsius_to_kelvin([0, 1])
    [273.15, 274.15]
    >>> celsius_to_kelvin((0, 1))
    (273.15, 274.15)
    >>> celsius_to_kelvin({0, 1})
    {273.15, 274.15}
    """
    return ...

8.7.11.2. Distance conversion doctest

English
  1. Write functions which convert distance given in kilometers to meters

  2. 1 km = 1000 m

  3. Distance cannot be negative

  4. Returned value must by float

  5. Write doctests

  6. Compare result with "Output" section (see below)

Polish
  1. Napisz funkcję, która przeliczy dystans podany w kilometrach na metry

  2. 1 km = 1000 m

  3. Dystans nie może być ujemny

  4. Zwracany dystans musi być float

  5. Napisz doctesty

  6. Compare result with "Output" section (see below)

Output
  • Test arguments:

    • -1

    • 0

    • 1

    • float

    • int

    • str -> expect TypeError

    • any other type -> expect TypeError

    • True

8.7.11.3. Fix URL Regex

  • Complexity level: hard

  • Lines of code to write: 0 lines (discussion only)

  • Estimated time of completion: 5 min

English
  1. Use data from "Input" section (see below)

  2. Pattern incorrectly classifies https://foo_bar.example.com/ as invalid

  3. Fix pattern without automated tests

  4. Don't break classification of the other cases

Polish
  1. Użyj danych z sekcji "Input" (patrz poniżej)

  2. Wyrażenie niepoprawnie klasyfikuje https://foo_bar.example.com/ jako nieprawidłowy

  3. Popraw wyrażenie bez testów automatycznych

  4. Nie zepsuj klasyfikacji pozostałych przypadków

Input
Listing 8.34. @diegoperini -- https://mathiasbynens.be/demo/url-regex
PATTERN = r'_^(?:(?:https?|ftp)://)(?:\S+(?::\S*)[email protected])?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:/[^\s]*)?$_iuS'

VALID = [
    'http://foo.com/blah_blah',
    'http://foo.com/blah_blah/',
    'http://foo.com/blah_blah_(wikipedia)',
    'http://foo.com/blah_blah_(wikipedia)_(again)',
    'http://www.example.com/wpstyle/?p=364',
    'https://www.example.com/foo/?bar=baz&inga=42&quux',
    'http://✪df.ws/123',
    'http://userid:[email protected]:8080',
    'http://userid:[email protected]:8080/',
    'http://[email protected]',
    'http://[email protected]/',
    'http://[email protected]:8080',
    'http://[email protected]:8080/',
    'http://userid:[email protected]',
    'http://userid:[email protected]/',
    'http://142.42.1.1/',
    'http://142.42.1.1:8080/',
    'http://➡.ws/䨹',
    'http://⌘.ws',
    'http://⌘.ws/',
    'http://foo.com/blah_(wikipedia)#cite-1',
    'http://foo.com/blah_(wikipedia)_blah#cite-1',
    'http://foo.com/unicode_(✪)_in_parens',
    'http://foo.com/(something)?after=parens',
    'http://☺.damowmow.com/',
    'http://code.google.com/events/#&product=browser',
    'http://j.mp',
    'ftp://foo.bar/baz',
    'http://foo.bar/?q=Test%20URL-encoded%20stuff',
    'http://مثال.إختبار',
    'http://例子.测试',
    'http://उदाहरण.परीक्षा',
    'http://-.~_!$&\'()*+,;=:%40:80%2f::::::@example.com',
    'http://1337.net',
    'http://a.b-c.de',
    'http://223.255.255.254',
    'https://foo_bar.example.com/',
]

INVALID = [
    'http://',
    'http://.',
    'http://..',
    'http://../',
    'http://?',
    'http://??',
    'http://??/',
    'http://#',
    'http://##',
    'http://##/',
    'http://foo.bar?q=Spaces',
    '//',
    '//a',
    '///a',
    '///',
    'http:///a',
    'foo.com',
    'rdar://1234',
    'h://test',
    'http:// shouldfail.com',
    ':// should fail',
    'http://foo.bar/foo(bar)baz quux',
    'ftps://foo.bar/',
    'http://-error-.invalid/',
    'http://a.b--c.de/',
    'http://-a.b.co',
    'http://a.b-.co',
    'http://0.0.0.0',
    'http://10.1.1.0',
    'http://10.1.1.255',
    'http://224.1.1.1',
    'http://1.1.1.1.1',
    'http://123.123.123',
    'http://3628126748',
    'http://.www.foo.bar/',
    'http://www.foo.bar./',
    'http://.www.foo.bar./',
    'http://10.1.1.1',
    'http://10.1.1.254',
]