具有复杂对象结构的pytest。打补丁、模拟、猴子补丁、重构还是放弃?

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

我的设计让我陷入了一个我认为是复杂的 pytest 问题。我确信我不知道采取正确的方法。我正在模拟一个名为 'Liverpool rummy' 的复杂纸牌游戏。用户界面使用 Kivy,但我在测试时遇到的问题可能会出现在任何 GUI 框架中,无论是 tkinter、QT、wxPython 还是其他任何框架。

在 rummy 模拟器中,我想在不实际启动 kivy 的情况下测试逻辑。我认为这意味着我需要在许多方法中模拟 self,因为这些方法通过“self.method_name”相互调用。我阅读了许多关于模拟 self 或模拟全局或模块级变量的帖子,这让我很困惑。我不想开始 kivy 至少有两个原因。首先,在大量初始化之后,它会进入“play_game”方法。虽然我可以从代码中调用它,而不是按一个按钮,它会立即得到一副洗好的牌和一堆弃牌,并随机向玩家(他们都是机器人)发一手牌,然后每个玩家拿走一个转弯。但是我需要做的测试是通过大约 50 个变化来设置这三个变量(牌组、弃牌、手牌)和 运行。其次,这似乎违背了尽可能多地隔离单元测试的目标。

因此,我没有实例化 classes 并测试方法,而是直接从 class 调用方法。这是一个非常简单的例子:

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

当您直接从 class 调用方法时,第一个参数必须是 class 标识符,通常是 'self' 如果我这样称呼它 Turn.evaluate_hand(goal, hand),那么我会得到 TypeError: evaluate_hand() missing 1 required positional argument: 'hand' 这里 goal 被认为是 self,然后 hand 被认为是 goal 并且没有生成错误消息的 hand

当如图所示调用时 Turn.evaluate_hand(None, goal, hand) 然后 运行 时的测试将到达: self.check_goal_met(hand, sets_needed, sets, runs_needed, runs),然后将为每个测试用例生成: AttributeError: 'NoneType' object has no attribute 'check_goal_met'。它需要查看 Turn class 的命名空间,包括方法 check_goal_met。但是 None 不是命名空间,因此当它有效地执行 None.check_goal_met 时,它会生成 AttributError.

您可以通过以下方式破解它:

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

但这只是 (a) 污染您的代码,以及 (b) 将问题推迟到下一次调用。因为 None 参数被传递给 check_goal_met,这将立即在行 self.can_go_out = True 上生成一个 AttributeError。当然,您可以通过将 can_go_out 添加到 return 元组来反过来破解它。

到现在为止,我断定整个方法都是错误的,因为它暗示我几乎不得不放弃在函数中使用属性。

为了让它工作,我需要实例化 Turn,而不仅仅是调用 class。但这似乎异常困难。这是对象结构。 "App" 和 "config" 是 kivy 对象。

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

我试了一天在测试用例中设置所有这些,最后放弃了。测试用例的全部意义在于将测试功能与应用程序的其余部分隔离开来,而不是在测试用例中重现应用程序其余部分的复杂性。

在我看来,如果我在测试用例中对 Turn 的调用中模拟 `self1,那是行不通的。这是一个模拟,而不是命名空间,因此对其他方法的调用将失败。

那么我可以模拟 class Turn,让代码找到方法并使用属性吗?我真的卡住了,我想我的整个方法一定是错的。

什么是正确的做法?有没有我可以研究如何做的文档? 提前致谢。

对我来说,听起来您应该重构代码以将游戏逻辑与 GUI 逻辑分离。

我认为在测试中需要使用 mock 通常表明代码可以设计得更好。 Here's an article 比我更清楚地解释了这个想法。一句特别相关的话:

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.

在您的情况下,GUI 和游戏逻辑之间存在紧密耦合。我建议将所有游戏逻辑移至 functions/classes 与 GUI 无关。理想情况下,尽可能多的逻辑将在 pure functions 中结束。这将使编写测试和 extend/maintain 代码变得更加容易。