Search code examples
unit-testingmockingkivypytestpytest-mock

pytest with complex object structure. To patch, mock, monkeypatch, refactor, or give up?


My design has led me into what I think is a complex pytest problem. I'm convinced I don't know the right approach to take. I'm simulating a complex card game, known as 'Liverpool rummy'. The user interface uses Kivy, but the problem I'm having with testing would probably appear with any GUI framework, be it tkinter, QT, wxPython, or whatever.

In rummy simulator, I want to test the logic without actually starting kivy. I think this means that I will need to mock the self in many methods, because the methods call each other via “self.method_name”. My reading of the many posts on mocking self or mocking global or module-level variables has left me pretty confused. I don't want to start kivy for at least two reasons. First, after a whole lot of initialization it'll get to the "play_game" method. While I can call that from code, rather than pushing a button, it'll immediately get a shuffled deck of cards and a discards pile and deal a random hand to the players (all of whom will be robots), who will then each take a turn. But what I need to do for testing is to set those three variables (deck, discard, hand) and run through around 50 variations. Second, that seems to defeat to goal of isolating a unit test as much as possible.

So instead of instantiating the classes, and testing the methods, I'm calling the methods directly from the class. Here is a very simplified example:

class Turn(Widget):
    """The Turn class contains the actions a player can perform, such as draw, pick-up, meld, and knock.
    Although this is an abstract class, it must inherit from Widget for the event processing to work."""

    def __init__(self, round):
        super().__init__()
        # omitting a bunch of attributes
        self.goal_met = False
        self.remaining_cards = []


    def evaluate_hand(self, goal, hand):
        """Compares hand to round goal and determines desired list of cards."""
        # lots of complicated stuff here
        self.check_goal_met(hand, sets_needed, sets, runs_needed, runs)
        # more logic
        return cards_needed
        
   def check_goal_met(self, hand, sets_needed, sets, runs_needed, runs):
        """Determine if the round goal has been met, and what are the remaining cards"""
        try:
            # lots of logic omitted
            remaining_cards = list(set(hand).difference(set(temp_hand)))
            if goal_met and remaining_cards == []:
                self.can_go_out = True
            return (goal_met, remaining_cards)

@pytest.mark.parametrize('goal, case, output', sr_test_cases)
def test_evaluate_hand(goal, case, output):
    round_goals = list(GOALS.keys())
    hand = build_test_hand(case)
    needs = Turn.evaluate_hand(None, goal, hand)  # using None in place of 'self'
    assert needs==output

When you call a method directly from the class, the first argument must be the class identifier, typically 'self' If I call it like this Turn.evaluate_hand(goal, hand), then I'll get TypeError: evaluate_hand() missing 1 required positional argument: 'hand' Here goal is considered self, then hand is considered goal and there is no hand which generates the error message.

When called as shown Turn.evaluate_hand(None, goal, hand) then the test when run will get to: self.check_goal_met(hand, sets_needed, sets, runs_needed, runs), and will then generate for every test case: AttributeError: 'NoneType' object has no attribute 'check_goal_met'. It needs to see the namespace for the Turn class, including the methods, check_goal_met. But None isn't a namespace and so when it effectively does None.check_goal_met it generates the AttributError.

You can hack this by doing:

def evaluate_hand(self, goal, hand):
    """Compares hand to round goal and determines desired list of cards."""
    # lots of complicated stuff here
    # self.check_goal_met(hand, sets_needed, sets, runs_needed, runs)
    if self is not None:
        goal_met, remaining_cards = self.check_goal_met(hand, sets_needed, sets, runs_needed, runs)
    else:
        goal_met, remaining_cards = Turn.check_goal_met(self, hand, sets_needed, sets, runs_needed, runs)
    # more logic

But all that does is (a) pollute your code, and (b) defer the problem to the next call. For the None argument gets passed to check_goal_met, and that will promptly generate an AttributeError on the line self.can_go_out = True. Of course you can in turn hack THAT by adding can_go_out to the return tuple.

By now I concluded this whole approach was wrong because it was implying that I'd all but have to abandon using attributes in the functions.

To get it to work, I need to instantiate Turn, not just call the class. But that seems unreasonably hard. Here is the object structure. "App" and "config" are kivy objects.

RummySimulator(App) → 
    BaseGame(object) →
        Sets_and_Runs(BaseGame) →
            Self.game.play_game →
                Round(app, game) →
                    Turn(round)
    Player(num, config) → ( Hand(), Melds(hand) )

I tried for a day to setup all this in the test case, and finally gave up. The whole point of a test case is to isolate the test function from the rest of the application, not reproduce the complication of the rest of the application in the test case.

It seems to me that if I mock `self1 in the call to Turn in the test case, that won't work. It's a mock, not a namespace, so the calls to the other methods will fail.

So can I mock the class Turn, in such a way that it lets the code find the methods and use the attributes? I'm really stuck, and think my whole approach must be wrong.

What is the right approach? And is there some doc I can study on how to do it? thanks in advance.


Solution

  • To me, it sounds like you should refactor your code to decouple the game logic from the GUI logic.

    I'm of the opinion that the need to use mocks in tests is usually a sign that the code could have been designed better. Here's an article that explains this idea more clearly that I could. One particularly relevant quote:

    The need to mock in order to achieve unit isolation for the purpose of unit tests is caused by coupling between units. Tight coupling makes code more rigid and brittle: more likely to break when changes are required. In general, less coupling is desirable for its own sake because it makes code easier to extend and maintain. The fact that it also makes testing easier by eliminating the need for mocks is just icing on the cake.

    From this we can deduce that if we’re mocking something, there may be an opportunity to make our code more flexible by reducing the coupling between units. Once that’s done, you won’t need the mocks anymore.

    In your case, the tight coupling is between the GUI and the game logic. I'd recommend moving all the game logic into functions/classes that have no connection to the GUI. Ideally, as much logic as possible will end up in pure functions. This will make it much easier to write tests, and to extend/maintain the code down the road.