Skip to content
Advertisement

Testing for None on a non Optional input parameter

Let’s say I have a python module with the following function:

def is_plontaria(plon: str) -> bool:
    if plon is None:
        raise RuntimeError("None found")

    return plon.find("plontaria") != -1

For that function, I have the unit test that follows:

def test_is_plontaria_null(self):
    with self.assertRaises(RuntimeError) as cmgr:
        is_plontaria(None)
    self.assertEqual(str(cmgr.exception), "None found")

Given the type hints in the function, the input parameter should always be a defined string. But type hints are… hints. Nothing prevents the user from passing whatever it wants, and None in particular is a quite common option when previous operations fail to return the expected results and those results are not checked.

So I decided to test for None in the unit tests and to check the input is not None in the function.

The issue is: the type checker (pylance) warns me that I should not use None in that call:

Argument of type "None" cannot be assigned to parameter "plon" of type "str" in function "is_plontaria"
  Type "None" cannot be assigned to type "str"

Well, I already know that, and that is the purpose of that test.

Which is the best way to get rid of that error? Telling pylance to ignore this kind of error in every test/file? Or assuming that the argument passed will be always of the proper type and remove that test and the None check in the function?

Advertisement

Answer

This is a good question. I think that silencing that type error in your test is not the right way to go.

Don’t patronize the user

While I would not go so far as to say that this is universally the right way to do it, in this case I would definitely recommend getting rid of your None check from is_plontaria.

Think about what you accomplish with this check. Say a user calls is_plontaria(None) even though you annotated it with str. Without the check he causes an AttributeError: 'NoneType' object has no attribute 'find' with a traceback to the line return plon.find("plontaria") != -1. The user thinks to himself “oops, that function expects a str. With your check he causes a RuntimeError ideally telling him that plon is supposed to be a str.

What purpose did the check serve? I would argue none whatsoever. Either way, an error is raised because your function was misused.

What if the user passes a float accidentally? Or a bool? Or literally anything other than a str? Do you want to hold the user’s hand for every parameter of every function you write?

And I don’t buy the “None is a special case”-argument. Sure, it is a common type to be “lying around” in code, but that is still on the user, as you pointed out yourself.

If you are using properly type annotated code (as you should) and the user is too, such a situation should never happen. Say the user has another function foo that he wants to use like this:

def foo() -> str | None:
    ...

s = foo()
b = is_plontaria(s)

That last line should cause any static type checker worth its salt to raise an error, saying that is_plontaria only accepts str, but a union of str and None was provided. Even most IDEs mark that line as problematic.

The user should see that before he even runs his code. Then he is forced to rethink and either change foo or introduce his own type check before calling your function:

s = foo()
if isinstance(s, str):
    b = is_plontaria(s)
else:
    # do something else


Qualifier

To be fair, there are situations where error messages are very obscure and don’t properly tell the caller what went wrong. In those cases it may be useful to introduce your own. But aside from those, I would always argue in the spirit of Python that the user should be considered mature enough to do his own homework. And if he doesn’t, that is on him, not you. (So long as you did your homework.)

There may be other situations, where raising your own type-errors makes sense, but I would consider those to be the exception.


If you must, use Mock

As a little bonus, in case you absolutely do want to keep that check in place and need to cover that if-branch in your test, you can simply pass a Mock as an argument, provided your if-statement is adjusted to check for anything other than str:

from unittest import TestCase
from unittest.mock import Mock


def is_plontaria(plon: str) -> bool:
    if not isinstance(plon, str):
        raise RuntimeError("None found")
    return plon.find("plontaria") != -1


class Test(TestCase):
    def test_is_plontaria(self) -> None:
        not_a_string = Mock()
        with self.assertRaises(RuntimeError):
            is_plontaria(not_a_string)
        ...

Most type checkers consider Mock to be a special case and don’t complain about its type, assuming you are running tests. mypy for example is perfectly happy with such code.

This comes in handy in other situations as well. For example, when the function being tested expects an instance of some custom class of yours as its argument. You obviously want to isolate the function from that class, so you can just pass a mock to it that way. The type checker won’t mind.

Hope this helps.

Advertisement