widdowquinn / pyani

Application and Python module for average nucleotide identity analyses of microbes.
http://widdowquinn.github.io/pyani/
MIT License
183 stars 54 forks source link

Use of `unittest.TestCase` assertion methods provides more informative error messages than `assert` #408

Closed baileythegreen closed 1 year ago

baileythegreen commented 1 year ago

This is just a general suggestion that for future development the assertion methods be considered as an alternative to plain assert; they may make it easier to understand why tests fail.

An extremely simple example of the difference between the two is shown here:

With assert:

assert a == b

---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/tmp/ipykernel_34706/2024525430.py in <module>
      1 a = 5
      2 b = 4
----> 3 assert a == b

AssertionError: 

and:

assert x == y

---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/tmp/ipykernel_45569/309068937.py in <module>
      1 x = "five"
      2 y = "fave"
----> 3 assert x == y

AssertionError: 

and with assertion methods:

from unittest import TestCase

# Create object for accessing unittest assertions
assertions = TestCase("__init__")

assertions.assertEqual(a, b)

---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/tmp/ipykernel_34706/715374219.py in <module>
----> 1 assertions.assertEqual(a, b)

~/Software/miniconda3/lib/python3.8/unittest/case.py in assertEqual(self, first, second, msg)
    910         """
    911         assertion_func = self._getAssertEqualityFunc(first, second)
--> 912         assertion_func(first, second, msg=msg)
    913 
    914     def assertNotEqual(self, first, second, msg=None):

~/Software/miniconda3/lib/python3.8/unittest/case.py in _baseAssertEqual(self, first, second, msg)
    903             standardMsg = '%s != %s' % _common_shorten_repr(first, second)
    904             msg = self._formatMessage(msg, standardMsg)
--> 905             raise self.failureException(msg)
    906 
    907     def assertEqual(self, first, second, msg=None):

AssertionError: 5 != 4

and:

assertions.assertEqual(x, y)

---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/tmp/ipykernel_45569/4074965748.py in <module>
      1 x = "five"
      2 y = "fave"
----> 3 assertions.assertEqual(x, y)

~/Software/miniconda3/lib/python3.8/unittest/case.py in assertEqual(self, first, second, msg)
    910         """
    911         assertion_func = self._getAssertEqualityFunc(first, second)
--> 912         assertion_func(first, second, msg=msg)
    913 
    914     def assertNotEqual(self, first, second, msg=None):

~/Software/miniconda3/lib/python3.8/unittest/case.py in assertMultiLineEqual(self, first, second, msg)
   1290             diff = '\n' + ''.join(difflib.ndiff(firstlines, secondlines))
   1291             standardMsg = self._truncateMessage(standardMsg, diff)
-> 1292             self.fail(self._formatMessage(msg, standardMsg))
   1293 
   1294     def assertLess(self, a, b, msg=None):

~/Software/miniconda3/lib/python3.8/unittest/case.py in fail(self, msg)
    751     def fail(self, msg=None):
    752         """Fail immediately, with the given message."""
--> 753         raise self.failureException(msg)
    754 
    755     def assertFalse(self, expr, msg=None):

AssertionError: 'five' != 'fave'
- five
?  ^
+ fave
?  ^

When it is not clear what the values are, the additional information may prove useful.

widdowquinn commented 1 year ago

I believe that working within the pytest framework provides additional information about assert statements to that reported in plain Python. See, for instance, this documentation.

Briefly, consider two files:

  1. plain.py
# plain Python

a = 5
b = 4

assert a == b
  1. pytest_python.py

# Python using Pytest

def test_function():
    a = 5
    b = 4
    assert a == b

Now, running the first script with Python gives the normal AssertionError

% python plain.py
Traceback (most recent call last):
  File "/Users/lpritc/Desktop/plain.py", line 6, in <module>
    assert a == b
AssertionError

but with pytest:

% pytest plain.py
================================================= test session starts =================================================
platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/lpritc/Desktop
plugins: anyio-3.5.0
collected 0 items / 1 error                                                                                           

======================================================= ERRORS ========================================================
______________________________________________ ERROR collecting plain.py ______________________________________________
plain.py:6: in <module>
    assert a == b
E   assert 5 == 4
=============================================== short test summary info ===============================================
ERROR plain.py - assert 5 == 4
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
================================================== 1 error in 0.19s ===================================================

I hope you would agree that, even with the bare assert statement, the pytest framework gives us adequate information to diagnose the issue, and arguably in a more elgant and readable way.

The second file illustrates the idiomatic way we would name this test for pytest, rather than using bare Python, to give:

% pytest pytest_python.py
================================================= test session starts =================================================
platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/lpritc/Desktop
plugins: anyio-3.5.0
collected 1 item                                                                                                      

pytest_python.py F                                                                                              [100%]

====================================================== FAILURES =======================================================
____________________________________________________ test_function ____________________________________________________

    def test_function():
        a = 5
        b = 4
>       assert a == b
E       assert 5 == 4

pytest_python.py:6: AssertionError
=============================================== short test summary info ===============================================
FAILED pytest_python.py::test_function - assert 5 == 4
================================================== 1 failed in 0.13s ==================================================

As we are using the pytest framework for testing on this project, I think it is simpler not to make additional imports from unittest at all, and in particular not to make a dummy object using unittest.TestCase("__init__") in order to use its methods. This looks to me to be a bit of a hybrid of the two frameworks that doesn't really bring the advantages of either. If we were were using the unittest framework (which, incidentally, we moved away from to adopt pytest) each test case would be defined as a subclass of unittest.TestCase and would automatically get these methods, e.g.

# Python with unittest

import unittest

class SimpleTest(unittest.TestCase):
    def test(self):
        a = 5
        b = 4
        self.assertEqual(a, b)

if __name__ == "__main__":
    unittest.main()

which would then produce:

% python unittest_python.py
F
======================================================================
FAIL: test (__main__.SimpleTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/lpritc/Desktop/unittest_python.py", line 9, in test
    self.assertEqual(a, b)
AssertionError: 5 != 4

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

FAILED (failures=1)

But this object-based approach isn't necessary with pytest, or consistent with its style.

My view is that we should be testing using idiomatic pytest wherever possible, and that the introduction of unittest assertion methods isn't necessary.