Thursday, March 23, 2017

Unit-testing and Code-coverage in Python [3]

Having determined what needs to be unit-tested, and generated mechanisms for making sure that the coverage of those code-entities is, itself, tested, it's time to turn some attention to how the testing should be managed. In other words, answering:

What needs to happen in order to test those elements to my satisfaction?

What is Unit-Testing Supposed to Do, Anyway?

Ultimately, the goal of a unit-test run could probably be well described as assuring that all of the possible paths through the code being tested have been executed and behaved in an expected fashion. Consider this example function:

def ExampleFunction( numberValue, textValue=None ):
    # Type- and value-check the arguments
    if type( numberValue ) not in ( int, long ):
        raise TypeError( 'numberValue must be an integer or '
            'long-integer value' )
    if textValue != None and type( textValue ) not in (str, unicode ):
        raise TypeError( 'textValue must be a string or '
            'unicode value, or None' )
    if numberValue < 1:
        raise ValueError( 'numberValue must be positive' )
    if textValue.strip() == '':
        raise ValueError( 'textValue cannot be empty or only '
            'white-space characters' )
    # Do the actual stuff...
    if textValue:
        if numberValue % 2:
            # Do odd-numbered cases
            chars = [ c for c in textValue ]
            chars.reverse()
            textValue = ''.join( chars )
        return textValue
This is a fairly simple function — two arguments in, a handful of acceptable types for each argument, some basic restrictions on argument values, and two possible return-value processes.

In order to assure that all the paths through the code of the function are executed, what values would have to be passed for the numberValue and textValue arguments? That is one of the key questions that should be asked for any unit-test process. The other would be some variation of what assertions need to be made against the returned value for each variation?

To answer the argument-values-passed question, I usually think in terms of good and bad values. For this function, those are pretty simple:

Argument Value-groups
Good values Bad values
numberValue Any int > 0;
Any long > 0;
Should test both even and odd values
Any int < 1;
Any long < 1;
Any value of any type other than int or long (floats, str or unicode values, instances of object, etc.)
textValue None (the default value);
Any non-empty str value;
Any non-empty unicode value;
'', u'' (empty text values);
' ', u' ', '\n', u'\n', '\t', u'\t' (only-white-space text-values);
Any value of any type other than str or unicode
Realistically, it's not always practical to test every legitimate value, though. There are, for example, an infinite number of integer and long-integer values that could be tested, and even if not all of those can be represented by a computer and the set of values were limited to the 264-1 values that a 64-bit machine could represent, there's not much point to testing with all of them — 1,023 and 1,024 will behave just like 1 and 2 would. I'm not even going to try to calculate the number of possible legitimate text-values...

The point of this is that it's perfectly legitimate to test with a representative subset of all the possible values for a given type.

And, in the interests of consistency (and not having to create the same lists of good and bad values from scratch over and over again) I'd like to have them available as some sort of relatively constant set of values, defined once, that could be easily filtered down to the relevant values as needed for any given good or bad value-set.

That's my goal for today's post. Call it a UnitTestValuePolicy.

Defining the UnitTestValuePolicy object

The strategy I'm going to pursue is to provide a baseline, default instance of the class, as a constant named UnitTestValuePolicy. That instance will be populated with the default unit-test values of all of the simple types that I expect test-methods to use, and will provide a number of filtering members (I'm thinking properties initially, if possible) that can be chained together to yield a final collection of values for any test-type that I can think of at present. The defaults that I'm contemplating at this point are:

_genericObject = object()
{
    'bools':[ True, False ],
    'falseish':[ 0.0, 0, 0L, None, '', u'', False ],
    'floats':[ -1.0, 0.0, 1.0, 2.0 ],
    'ints':[ -1, 0, 1, 2 ],
    'longs':[ 
        -sys.maxint *2 - 1, -1L, 0L, 1L, 2L, sys.maxint *2
        ],
    'none':[ None ],
    'objects':[ _genericObject ],
    'strings':[
        '',
        ' ',
        '\t',
        '\r',
        '\n',
        'word',
        'multiple words',
        'A complete sentence.',
        'Multiple sentences. Separated with punctuation.',
        'String\tcontaining a tab',
        'Multiline\nstring',
    ],
    'trueish':[ 1.0, 0.5, 1, 1L, 'a', u'a', 
        _genericObject, True ],
    'unicodes':[
        u'',
        u' ',
        u'\t',
        u'\r',
        u'\n',
        u'word',
        u'multiple words',
        u'A complete sentence.',
        u'Multiple sentences. Separated with punctuation.',
        u'String\tcontaining a tab',
        u'Multiline\nstring',
    ],
}
In use, applying the object to the example method above would involve
  • Getting a list of positive whole numbers:
    goodNumbers = ( UnitTestValuePolicy.Integers + UnitTestValuePolicy.Longs ).Positive
    which would yield the 'ints' list plus the 'longs' list, then filter those down to only the values greater than or equal to zero:
    [ 1, 2, 1L, 2L, sys.maxint ]
  • Getting a similar list of all string- and unicode values, and removing any that are empty, or that only contain whitespace, and adding the 'none' list to that result (since None is a valid value for the textValue argument):
    goodTexts = ( UnitTestValuePolicy.Strings + UnitTestValuePolicy.Unicodes ).NotEmpty.HasText + UnitTestValuePolicy.None
    [ 'word', 'multiple words', 'A complete sentence,', 'Multiple sentences. Separated with punctuation.', 'String\tcontaining a tab', 'Multiline\nstring', u'word', u'multiple words', u'A complete sentence,', u'Multiple sentences. Separated with punctuation.', u'String\tcontaining a tab', u'Multiline\nstring', ]
  • Creating badNumbers and badTexts lists that contain values that should raise exceptions:
    badNumbers = UnitTestValuePolicy.All.remove( goodNumbers )
    badTexts = UnitTestValuePolicy.All.remove( goodTexts )

A test-method implementation for ExampleFunction could look soemthing like this:

def testExampleFunction( self ):
    goodNumbers = ( UnitTestValuePolicy.Integers + 
        UnitTestValuePolicy.Longs ).Positive
    goodTexts = ( UnitTestValuePolicy.Strings + 
        UnitTestValuePolicy.Unicodes ).NotEmpty.HasText + 
        UnitTestValuePolicy.None
    badNumbers = UnitTestValuePolicy.All.remove( goodNumbers )
    badTexts = UnitTestValuePolicy.All.remove( goodTexts )
    # Test all the "good" permutations:
    for goodNumber in goodNumbers:
        for goodText in goodTexts:
            actual = ExampleFunction( goodNumber, goodText )
            # TODO: Figure out how to generate an "expected" value
            # for comparison... Right now, this will fail!
            self.assertEquals( actual, expected, 
                'ExampleFunction( %d, %s <%s> ) should '
                'return "%s" (%s), but returned "%s" (%s)' % ( 
                    goodNumber, goodText, type( goodText ).__name__ ),
                    expected, type( expected ).__name__,
                    actual, type( actual ).__name__
                )
    # Test all the "bad" permutations with a single "good" value, 
    # since all good values have been proven to work correctly by 
    # now...
    goodNumber = goodNumbers[ 0 ]
    goodText = goodTexts[ 0 ]
    # Test goodNumber/badText possibilities
    for badText in badTexts:
        try:
            result = ExampleFunction( goodNumber, badText )
        except ( TypeError, ValueError ):
            # Expected exception, so...
            pass
        except Exception, error:
            self.fail( 'ExampleFunction( %d, %s <%s> ) should '
                'have raised a TypeError or ValueError, but a %s was '
                'encountered instead: %s' % (
                goodNumber, badText, type( badText ).__name__, 
                error.__class__.__name__, error
                )
            )
    # Test badNumber/goodText possibilities
    for badNumber in badNumbers:
        try:
            result = ExampleFunction( badNumber, goodText )
        except ( TypeError, ValueError ):
            # Expected exception, so...
            pass
        except Exception, error:
            self.fail( 'ExampleFunction( %d, %s <%s> ) should '
                'have raised a TypeError or ValueError, but a %s was '
                'encountered instead: %s' % (
                badNumber, goodText, type( goodText ).__name__, 
                error.__class__.__name__, error
                )
            )
If you do the math, that's maybe a pretty formidable set of assertions that will be made for a function with only two arguments: The count of the goodNumber values times the count of the goodText values is 5 × 13, so 65 good-variant assertions, plus 89 bad-variant assertions (there are 55 All values to start with, once each for numbers and texts, so 110 values, then removing the 21 good values...). Total: 154 assertions. That includes the items in the 'trueish' and 'falseish' lists, though, which might not be needed in general tests, and is wihout the removal of any duplications, so that number should go down, maybe substantially. Even if the bad values lists collapse pretty substantially, I'd still expect somewhere in the neighborhood of 75 test-assertions, though.

Filtering Phrases and Actions

So, the big challenge with this approach is probably to figure out what the various filtering phraes (actions) need to be implemented, and implementing them. In order for this to work the way that I want it to, each additional phrase or action in a chain has to modify the list of values in some fashion, while being able to allow continued modification by other phases/actions. For example, the good- and bad-value items noted above would work out to something like this, every step of the way:

goodNumbers

( UnitTestValuePolicy.Integers
[ -1, 0, 1, 2 ]
+ UnitTestValuePolicy.Longs )
[ -1, 0, 1, 2, -sys.maxint - 1, -1L, 0L, 1L, 2L, sys.maxint ]
.Positive
[ 1, 2, 1L, 2L, sys.maxint ]

goodTexts

( UnitTestValuePolicy.Strings
[ '', ' ', '\t', '\r', '\n', 'word', 'multiple words', 
'A complete sentence.', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring' ]
+ UnitTestValuePolicy.Unicodes )
[ '', ' ', '\t', '\r', '\n', 'word', 'multiple words', 
'A complete sentence', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', u'', u' ', 
u'\t', u'\r', u'\n', u'word', u'multiple words', 
u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.', 
u'String\tcontaining a tab', u'Multiline\nstring', ]
.NotEmpty
[ ' ', '\t', '\r', '\n', 'word', 'multiple words', 
'A complete sentence', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', u' ', 
u'\t', u'\r', u'\n', u'word', u'multiple words', 
u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.',
 u'String\tcontaining a tab', u'Multiline\nstring', ]
.HasText
[ 'word', 'multiple words', 'A complete sentence', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', u'word', 
u'multiple words', u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.', 
u'String\tcontaining a tab', u'Multiline\nstring', ]
+ UnitTestValuePolicy.None
[ 'word', 'multiple words', 'A complete sentence', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', u'word', 
u'multiple words', u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.', 
u'String\tcontaining a tab', u'Multiline\nstring', 
None ]

badNumbers

UnitTestValuePolicy.All
[ u'', u' ', u'\t', u'\r', u'\n', u'word', 
u'multiple words', u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.', 
u'String\tcontaining a tab', u'Multiline\nstring', 
None, <object>, -1, 0, 1, 2, True, False, 0.0, 0, 
0L, None, '', u'', False, -18446744073709551615L, -1L, 0L, 
1L, 2L, 18446744073709551614L, 1.0, 0.5, 1, 1L, 'a', 
u'a', <object>, True, '', ' ', '\t', '\r', '\n', 
'word', 'multiple words', 'A complete sentence.', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', -1.0, 
0.0, 1.0, 2.0 ]
.remove( goodNumbers )
[ u'', u' ', u'\t', u'\r', u'\n', u'word', 
u'multiple words', u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.', 
u'String\tcontaining a tab', u'Multiline\nstring', None, 
<object>, -1, 0, True, False, 0.0, 0, 0L, None, '', u'', 
False, -18446744073709551615L, -1L, 0L, 1.0, 0.5, 1, 1L, 
'a', u'a', <object>, True, '', ' ', '\t', '\r', 
'\n', 'word', 'multiple words', 'A complete sentence.', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', -1.0, 0.0, 
1.0, 2.0 ]

badTexts

UnitTestValuePolicy.All
[ u'', u' ', u'\t', u'\r', u'\n', u'word', 
u'multiple words', u'A complete sentence.', 
u'Multiple sentences. Separated with punctuation.', 
u'String\tcontaining a tab', u'Multiline\nstring', 
None, <object>, -1, 0, 1, 2, True, False, 0.0, 0, 
0L, None, '', u'', False, -18446744073709551615L, -1L, 0L, 
1L, 2L, 18446744073709551614L, 1.0, 0.5, 1, 1L, 'a', 
u'a', <object>, True, '', ' ', '\t', '\r', '\n', 
'word', 'multiple words', 'A complete sentence.', 
'Multiple sentences. Separated with punctuation.', 
'String\tcontaining a tab', 'Multiline\nstring', -1.0, 
0.0, 1.0, 2.0 ]
.remove( goodTexts )
[ u'', u' ', u'\t', u'\r', u'\n', <object>, -1, 0, 1, 2, 
True, False, 0.0, 0, 0L, None, '', u'', False, 
-18446744073709551615L, -1L, 0L, 1L, 2L, 
18446744073709551614L, 1.0, 0.5, 1, 1L, 'a', u'a', 
<object>, True, '', ' ', '\t', '\r', '\n', -1.0, 0.0, 
1.0, 2.0 ]

Nailing Down the Filtering Actions

In order for this approach to work, the UnitTestValuePolicy object that's in use needs to be able to return a variety of filtered iterables (they might be lists, or they might be some other iterable-type, I haven't decided yet), and each of those needs to be able to apply the same filter processes. In the interests of maintaining as much flexibility as possible, any return-instance should be able to apply any of the filtering criteria or methods — that will allow for a single instance to be filtered down in more complex fashions. Though I'm not completely sure that I'll need that degree of flexibility, I'd rather allow it for now and remove it later if necessary than have to go back and retrofit, for example, a numeric return-type and a text return-type and a boolean return-type to allow it later.

I'm pretty sure that most of the simple types' filtering capabilities fall into these categories:

.All
Returns all values of all types.
.Numeric
Returns all numeric values of all types. Numeric filters (available here and in the numeric-type items below) are:
.Even
Returns only even number values
.Odd
Returns only odd number values
.Positive
Returns numbers greater than zero (zero is neither positive nor negative)
.Negative
Returns numbers less than zero
.Zero
Returns numbers that are equal to zero (which would include 0 <int>, 0L <long> and 0.0 <float>
.NonPositive
Returns numbers less than or equal to zero
.NonNegative
Returns numbers greater than or equal to zero
.NonZero
Returns numbers that are not equal to zero
Type-based numeric filters are:
.Integers
Returns only non-long integers
.Floats
Returns only floats
.Longs
Returns only long integers
.Text
Returns all of the text-type values, of either str or unicode — and this I may also need to be able to expand to include special text-types that I may want to define later, like email addresses and URIs. The type-based filters would include:
.Strings
Returns only those values that are str types
.Unicodes
Returns only those values that are unicode types
Text-values cover a lot of variations that can and will show up in unit-tests, including:
.NotEmpty
Returns only those values that are not empty
.HasText
Returns only those values that have characters that are not whitespace (so, no values that are nothing but whitespace)
.SingleWords
Returns only those values that have no whitespace (spaces, tabs, line-breaks or carriage-returns)
.SingleLine
Returns only those values that have no line-breaks or carriage-returns
.NoTabs
Returns only those values that have no tabs
.Multiline
Returns only those values that have line-breaks and/or carriage-returns
.TagName
Returns only those values that are legitimate XML tag-names (I know that I'm going to need this by the time I get to parsing and generating markup, so I'm including it now)
This list will almost certainly grow, and perhaps grow quickly, as new text-types to be tested surface.
True and False (and True-ish and False-ish) Values
Returns values that are true (or true-ish) or false (or false-ish). Like at least a few other languages, Python will allow a non-zero, non-empty value to evaluate as True for evaluation purposes in an if...elif...else decision-structure, so the inclusion of true-ish ('trueish') and false-ish ('falseish') values is part of the default.
.Strict
Returns only those values that are actual bool types (True and False)
.StrictAndNone
The same as .Strict, but with None added to the mix
.StrictAndNumeric
Returns only those values that are actual bool types, plus 1 and 0 values as integers, long-integers, and floating-point types.
.StrictNumericNone
The same as .StrictAndNumeric, but with None added to the mix
Other global actions
.remove( values <iterable> )
Removes the items in values from the current results.
I'm also going to consider whether I'd want/need some format conversion capabilities. One consideration in this respect is that if I do provide that, the results might lose the ability to be meaningfully filtered after this sort of conversion in any of the non-text categories...
.AsString
Returns all current values as strings
.AsUnicode
Returns all current values as unicode

Chaining Filtering Actions

The other critical part of the structure of UnitTestValuePolicy, or at least of the collections of test-values it returns, is the ability to chain different filtering actions together. Having given it some thought, I'm going to implement those as properties, which will allow the sort of chaining-structure I noted earlier: ( UnitTestValuePolicy.Integers + UnitTestValuePolicy.Longs ).Positive. That also more or less mandates the underlying Python data-type that I'm going to be using, or at least returning: a list — if only to support the concatenation of values with the + operator. I'll extend that basic list type into a new class (TestValues) in order to provide the filtering actions. I could also create my own list-like class and just emulate the functionality of a list as well, but I don't think I'll need to go to quite that depth.

The final breakdown, grouped and sorted, of all the filtering properties and methods will look something like this, then:

  • Boolean filtering properties
    • Boolean
    • Strict
    • StrictAndNone
    • StrictAndNumeric
    • StrictNumericNone
  • Numeric filtering properties
    • Numeric
    • Floats
    • Integers
    • Longs
    • Even
    • Negative
    • NonNegative
    • NonPositive
    • NonZero
    • Odd
    • Positive
    • Zero
  • Text filtering properties
    • Text
    • Strings
    • Unicodes
    • HasText
    • Multiline
    • NoTabs
    • NotEmpty
    • SingleLine
    • SingleWords
    • TagName
  • Formatting properties (maybe, since they will be destructive of other filtering)
    • AsString
    • AsUnicode
  • Other filtering (methods)
    • remove( values <iterable> )

Alright. I didn't get much into any actual implementation of either of the UnitTestValuePolicy or TestValues classes, but there's been a pretty substantial amount of discovery about what they need to be able to do in order to meet my needs, and this post is long enough that I don't want to dive into the nuts-and-bolts implementation today — I suspect it'd make for a ridiculously long post — so that's where I'll pick up next time: Actual implementations of UnitTestValuePolicy and TestValues.

No comments:

Post a Comment