11

I'm writing some tests for a library using pytest. I want to try a number of test cases for each function exposed by the library, so I've found it convenient to group the tests for each method in a class. All of the functions I want to test have the same signature and return similar results, so I'd like to use a helper method defined in a superclass to do some assertions on the results. A simplified version would run like so:

class MyTestCase:
    function_under_test: Optional[Callable[[str], Any]] = None

    def assert_something(self, input_str: str, expected_result: Any) -> None:
        if self.function_under_test is None:
            raise AssertionError(
                "To use this helper method, you must set the function_under_test"
                "class variable within your test class to the function to be called.")

        result = self.function_under_test.__func__(input_str)
        assert result == expected_result
        # various other assertions on result...


class FunctionATest(MyTestCase):
    function_under_test = mymodule.myfunction

    def test_whatever(self):
        self.assert_something("foo bar baz")

In assert_something, It's necessary to call __func__() on the function since assigning a function to a class attribute makes it a bound method of that class -- otherwise self will be passed through as the first argument to the external library function, where it doesn't make any sense.

This code works as intended. However, it yields the MyPy error:

"Callable[[str], Any]" has no attribute "__func__"

Based on my annotation, it's correct that this isn't a safe operation: an arbitrary Callable may not have a __func__ attribute. However, I can't find any type annotation that would indicate that the function_under_test variable refers to a method and thus will always have __func__. Am I overlooking one, or is there another way to tweak my annotations or accesses to get this working with type-checking?

Certainly, there are plenty of other ways I could get around this, some of which might even be cleaner (use an Any type, skip type checking, use a private method to return the function under test rather than making it a class variable, make the helper method a function, etc.). I'm more interested in whether there's an annotation or other mypy trick that would get this code working.

Soren Bjornstad
  • 1,292
  • 1
  • 14
  • 25
  • The most typesafe way I can think of is to call `type(self).function_under_test` instead. I don't believe that there is a mypy annotation that does the thing you want. I believe you could build a protocol type that defines `__func__` and assign your function to it, but I see no point. – Markus Unterwaditzer Sep 24 '19 at 20:11
  • 1
    @MarkusUnterwaditzer I've tested it, a Protocol with `__self__` and `__func__` will not work. Because it seems that mypy has almost no notion of a method. If you reveal_type() on any methods you will see that mypy just recognize them as plain Callables. – kawing-chiu Mar 07 '20 at 13:37

1 Answers1

0

Callable only makes sure that your object has the __call__ method.

You problem is your call self.function_under_test.__func__(input_str) you should just call your function self.function_under_test(input_str)

See below your example without mypy complaints (v0.910)

from typing import Any, Callable, Optional


class MyTestCase:
    function_under_test: Optional[Callable] = None

    def myfunction_wrap(self, *args, **kwargs):
        raise NotImplementedError

    def assert_something(self, input_str: str, expected_result: Any) -> None:
        if self.function_under_test is None:
            raise AssertionError(
                "To use this helper method, you must set the function_under_test"
                "class variable within your test class to the function to be called.")

        result = self.myfunction_wrap(input_str)
        assert result == expected_result
        # various other assertions on result...


def myfunction(a: str) -> None:
    ...


class FunctionATest(MyTestCase):
    def myfunction_wrap(self, *args, **kwargs):
        myfunction(*args, **kwargs)

    def test_whatever(self):
        self.assert_something("foo bar baz")

Edit1: missed the point of the questio, moved function inside a wrapper function

Cesc
  • 904
  • 1
  • 9
  • 17
  • This does indeed get rid of the mypy errors, but only by making it do something different. Your version doesn't actually work when run: you get `TypeError: myfunction() takes 1 positional argument but 2 were given`. That's because, as I noted in my question, `function_under_test` is a class attribute, and when you don't call `.__func__()`, Python tries to pass `self` as the first argument, which is incorrect since `myfunction` is not a method. – Soren Bjornstad Sep 28 '21 at 00:32
  • I missed that point, what about putting your function in a wrapper function? (edited my previous answer) – Cesc Sep 28 '21 at 06:31