【问题标题】:Correct way to TDD methods that calls other methods调用其他方法的 TDD 方法的正确方法
【发布时间】:2011-09-03 21:13:47
【问题描述】:

我需要一些关于 TDD 概念的帮助。假设我有以下代码

def execute(command)
  case command
  when "c"
    create_new_character
  when "i"
    display_inventory
  end
end

def create_new_character
  # do stuff to create new character
end

def display_inventory
  # do stuff to display inventory
end

现在我不确定要为什么编写单元测试。如果我为execute 方法编写单元测试,那么我对create_new_characterdisplay_inventory 的测试还不够吗?还是我当时测试了错误的东西?我对execute 方法的测试是否应该只测试执行是否传递给正确的方法并停在那里?那我应该写更多的单元测试来专门测试create_new_characterdisplay_inventory吗?

【问题讨论】:

    标签: ruby tdd


    【解决方案1】:

    我假设既然你提到了 TDD,那么有问题的代码实际上并不存在。如果是这样,那么您不是在做真正的 TDD,而是在做 TAD(开发后测试),这自然会导致诸如此类的问题。在 TDD 中,我们从测试开始。看来您正在构建某种类型的菜单或命令系统,所以我将使用它作为示例。

    describe GameMenu do
      it "Allows you to navigate to character creation" do
        # Assuming character creation would require capturing additional
        # information it violates SRP (Single Responsibility Principle)
        # and belongs in a separate class so we'll mock it out.
        character_creation = mock("character creation")
        character_creation.should_receive(:execute)
    
        # Using constructor injection to tell the code about the mock
        menu = GameMenu.new(character_creation)
        menu.execute("c")
      end
    end
    

    这个测试会导致一些类似于下面的代码(记住,只要足够的代码使测试通过,就不会更多了)

    class GameMenu
      def initialize(character_creation_command)
        @character_creation_command = character_creation_command
      end
    
      def execute(command)
        @character_creation_command.execute
      end
    end
    

    现在我们将添加下一个测试。

    it "Allows you to display character inventory" do
      inventory_command = mock("inventory")
      inventory_command.should_receive(:execute)
      menu = GameMenu.new(nil, inventory_command)
      menu.execute("i")
    end
    

    运行此测试将引导我们实现如下实现:

    class GameMenu
      def initialize(character_creation_command, inventory_command)
        @inventory_command = inventory_command
      end
    
      def execute(command)
        if command == "i"
          @inventory_command.execute
        else
          @character_creation_command.execute
        end
      end
    end
    

    这个实现将我们引向一个关于我们的代码的问题。当输入无效命令时,我们的代码应该怎么做?一旦我们确定了该问题的答案,我们就可以实施另一个测试。

    it "Raises an error when an invalid command is entered" do
      menu = GameMenu.new(nil, nil)
      lambda { menu.execute("invalid command") }.should raise_error(ArgumentError)
    end
    

    这推动了对execute 方法的快速更改

      def execute(command)
        unless ["c", "i"].include? command
          raise ArgumentError("Invalid command '#{command}'")
        end
    
        if command == "i"
          @inventory_command.execute
        else
          @character_creation_command.execute
        end
      end
    

    现在我们已经通过了测试,我们可以使用 Extract Method 重构将命令的验证提取到 Intent Revealing Method

      def execute(command)
        raise ArgumentError("Invalid command '#{command}'") if invalid? command
    
        if command == "i"
          @inventory_command.execute
        else
          @character_creation_command.execute
        end
      end
    
      def invalid?(command)
        !["c", "i"].include? command
      end
    

    现在我们终于可以解决您的问题了。由于 invalid? 方法是通过重构现有的被测代码来驱动的,因此无需为它编写单元测试,它已经被覆盖并且不能独立存在。由于我们现有的测试未对库存和角色命令进行测试,因此需要独立进行测试驱动。

    请注意,我们的代码可能会更好,所以在测试通过时,让我们再清理一下。条件语句表明我们违反了OCP(开闭原则),我们可以使用Replace Conditional With Polymorphism重构来移除条件逻辑。

    # Refactored to comply to the OCP.
    class GameMenu
      def initialize(character_creation_command, inventory_command)
        @commands = {
          "c" => character_creation_command,
          "i" => inventory_command
        }
      end
    
      def execute(command)
        raise ArgumentError("Invalid command '#{command}'") if invalid? command
        @commands[command].execute
      end
    
      def invalid?(command)
        !@commands.has_key? command
      end
    end
    

    现在我们重构了这个类,这样一个额外的命令只需要我们向命令哈希添加一个额外的条目,而不是改变我们的条件逻辑以及invalid? 方法。

    所有测试应该仍然通过,我们几乎完成了我们的工作。一旦我们测试驱动各个命令,您就可以返回到初始化方法并为命令添加一些默认值,如下所示:

      def initialize(character_creation_command = CharacterCreation.new,
                     inventory_command = Inventory.new)
        @commands = {
          "c" => character_creation_command,
          "i" => inventory_command
        }
      end
    

    最后的测试是:

    describe GameMenu do
      it "Allows you to navigate to character creation" do
        character_creation = mock("character creation")
        character_creation.should_receive(:execute)
        menu = GameMenu.new(character_creation)
        menu.execute("c")
      end
    
      it "Allows you to display character inventory" do
        inventory_command = mock("inventory")
        inventory_command.should_receive(:execute)
        menu = GameMenu.new(nil, inventory_command)
        menu.execute("i")
      end
    
      it "Raises an error when an invalid command is entered" do
        menu = GameMenu.new(nil, nil)
        lambda { menu.execute("invalid command") }.should raise_error(ArgumentError)
      end
    end
    

    最后的GameMenu 看起来像:

    class GameMenu
      def initialize(character_creation_command = CharacterCreation.new,
                     inventory_command = Inventory.new)
        @commands = {
          "c" => character_creation_command,
          "i" => inventory_command
        }
      end
    
      def execute(command)
        raise ArgumentError("Invalid command '#{command}'") if invalid? command
        @commands[command].execute
      end
    
      def invalid?(command)
        !@commands.has_key? command
      end
    end
    

    希望有帮助!

    布兰登

    【讨论】:

    • 感谢您的详细回答。你给了我很多东西去咀嚼和思考。关于您的示例,唯一真正困扰我的是 GameMenu 初始化程序在添加大量命令后会变得非常长。如果我必须跟踪我的新“显示地图”命令是列表中的 10 个参数,那么测试很容易搞砸。有什么好的解决方案吗?
    • @Dty 绝对。我曾考虑过。我认为对于这个小例子来说,这没什么大不了的,但你确认它是/可能是。有几种方法可以处理它。首先想到的是添加一个 register_menu_command 可以在外部调用以注册命令。第二种方法是用 Builder Pattern 替换该参数列表,并简单地传入一个生成散列的 MenuBuilder。您可以在测试中配置构建器。我可能更喜欢构建器解决方案。
    • 我仍然担心这个解决方案有点过度设计,但我从中学到了很多,它肯定回答了我的问题。谢谢!
    【解决方案2】:

    我将为create_new_characterdisplay_inventory 创建常规测试,最后测试execute,它只是一个包装函数,设置期望以检查是否调用了适当的命令(并返回结果)。类似的东西:

    def test_execute
      commands = {
        "c" => :create_new_character, 
        "i" => :display_inventory,
      }
      commands.each do |string, method|  
        instance.expects(method).with().returns(:mock_return)
        assert_equal :mock_return, instance.execute(string)
      end
    end
    

    【讨论】:

      【解决方案3】:

      考虑重构,以便负责解析命令的代码(在您的情况下为execute)独立于实现操作的代码(即create_new_characterdisplay_inventory)。这使得模拟操作和独立测试命令解析变得容易。 希望独立测试不同的部分。

      【讨论】:

      • 我不确定我理解你的意思。例如,我已经感觉到命令的解析和动作的执行是独立的。你能给我看一个简短的代码示例吗?也许这会帮助我理解。
      猜你喜欢
      • 2021-10-14
      • 1970-01-01
      • 1970-01-01
      • 2017-06-25
      • 1970-01-01
      • 2018-08-26
      • 1970-01-01
      • 2017-02-26
      • 2011-04-15
      相关资源
      最近更新 更多