当前位置: 首页 > 工具软件 > cash-cli > 使用案例 >

测试单元模拟httpcli_模拟对象的单元测试

蒲昊
2023-12-01

单元测试已被广泛接受为软件开发的“最佳实践”。 编写对象时,还必须提供一个自动化测试类,其中包含使对象步调一致的方法,使用各种参数调用其各种公共方法并确保返回的值合适。

当您处理简单的数据或服务对象时,编写单元测试非常简单。 但是,许多对象依赖于其他对象或基础结构层。 当测试这些对象时,实例化这些协作者通常很昂贵,不切实际或效率低下。

例如,要对使用数据库的对象进行单元测试,安装,配置和播种数据库的本地副本,运行测试然后再次关闭本地数据库可能会很麻烦。 模拟对象提供了摆脱这种困境的方法。 模拟对象符合真实对象的接口,但是仅具有足以欺骗测试对象并跟踪其行为的代码。 例如,用于特定单元测试的数据库连接可能会记录查询,同时始终返回相同的硬连线结果。 只要要测试的类的行为符合预期,它就不会注意到差异,并且单元测试可以检查是否发出了正确的查询。

模拟在中间

使用模拟对象进行测试的常见编码样式是:

  • 创建模拟对象的实例
  • 在模拟对象中设置状态和期望
  • 以模拟对象作为参数调用域代码
  • 验证模拟对象的一致性

尽管此模式在许多情况下非常有效,但有时无法将模拟对象传递到要测试的对象中。 相反,该对象旨在创建,查找或以其他方式获取其协作者。

例如,被测试的对象可能需要获得对Enterprise JavaBean(EJB)组件或远程对象的引用。 或者被测对象可能会使用副作用,这些副作用在单元测试中可能是不希望的,例如删除文件的File对象。

常识表明,这种情况提供了重构对象的机会,使其更易于测试。 例如,您可以更改方法签名,以便传递协作者对象。

Nicholas Lesiecki指出,重构并非总是可取的,它也不总是使代码更清晰或更易于理解。 在许多情况下,更改方法签名以使协作者成为参数将导致该方法的原始调用者内部的代码混乱,未经测试。

问题的核心是对象正在“内部”获得这些对象。 任何解决方案都必须适用于此创建代码的所有出现。 为了解决此问题,Lesiecki使用查找方面或创建方面。 在此解决方案中,执行查找的代码将自动替换为返回模拟对象的代码。

由于AspectJ对于某些用户而言不是一种选择,因此在本文中我们提供了另一种方法。 因为从根本上讲,这是一种重构,所以我们将遵循Martin Fowler在他的开创性著作《 重构:改进现有代码的设计》中建立的表示惯例(请参阅参考资料 )。 (我们的代码基于JUnit,这是Java编程中最流行的单元测试框架,尽管它绝不是特定于JUnit的。)

重构:提取并覆盖工厂方法

重构是对代码的更改,可以保留原始功能,但可以更改代码的设计,以使其更简洁,更有效且更易于测试。 本节提供了“提取和替代”工厂方法重构的分步说明。

问题:被测试的对象创建一个协作对象。 该协作者必须替换为模拟对象。

重构前的代码
class Application {
...
  public void run() {
    View v = new View();
    v.display();
...

解决方案:将创建代码提取到工厂方法中,在测试子类中重写此工厂方法,然后使被重写的方法返回一个模拟对象。 最后,如果可行,添加一个需要原始对象的工厂方法返回正确类型的对象的单元测试:

重构后的代码
class Application {
...
  public void run() {
    View v = createView();
    v.display();
...
  protected View createView() {
    return new View();
  }
...
}

此重构启用了清单1中所示的单元测试代码:

清单1.单元测试代码
class ApplicationTest extends TestCase {

  MockView mockView = new MockView();

  public void testApplication {
    Application a = new Application() {
      protected View createView() {
        return mockView;
      }
    };
    a.run();
    mockView.validate();
  }

  private class MockView extends View
  {
    boolean isDisplayed = false;

    public void display() {
      isDisplayed = true;
    }

    public void validate() {
      assertTrue(isDisplayed);
    }
  }
}

的角色

此设计引入了系统中的对象扮演的以下角色:

  • 目标对象 :被测试的对象
  • 协作对象 :目标创建或获取的对象
  • 模拟对象 :遵循模拟对象模式的协作者的子类(或实现)
  • 专业化对象 :目标的子类,该子类重写创建方法以返回模拟而不是协作者

机械学

重构包括许多小的技术步骤。 这些统称为力学 。 如果您像菜谱食谱一样密切关注机械原理,那么您应该能够轻松地学习重构。

  1. 识别所有创建或获得协作者的代码。
  2. 将提取方法重构应用于此创建代码,创建工厂方法(在Fowler的书的第110页上进行了讨论; 有关更多信息,请参见“ 相关主题”部分)。
  3. 确保目标对象及其子类可以访问工厂方法。 (在Java语言中,使用protected关键字)。
  4. 在测试代​​码中,创建一个模拟对象,该对象实现与协作者相同的接口。
  5. 在测试代​​码中,创建一个扩展(专门化)目标的特殊化对象。
  6. 在特殊化对象中,重写创建方法以返回可容纳您的测试的模拟对象。
  7. 可选:创建一个单元测试,以确保原始目标对象的工厂方法仍返回正确的非模拟对象。

示例:ATM

假设您正在为银行的自动柜员机编写测试。 这些测试之一可能类似于清单2:

清单2.引入模拟对象之前的初始单元测试
public void testCheckingWithdrawal() {
    float startingBalance = balanceForTestCheckingAccount();

    AtmGui atm = new AtmGui();
    insertCardAndInputPin(atm);

    atm.pressButton("Withdraw");
    atm.pressButton("Checking");
    atm.pressButtons("1", "0", "0", "0", "0");
    assertContains("$100.00", atm.getDisplayContents());
    atm.pressButton("Continue");

    assertEquals(startingBalance - 100, 
balanceForTestCheckingAccount());
  }

此外, AtmGui类内部的匹配代码可能类似于清单3:

清单3.重构之前的生产代码
private Status doWithdrawal(Account account, float amount) {
    Transaction transaction = new Transaction();
    transaction.setSourceAccount(account);
    transaction.setDestAccount(myCashAccount());
    transaction.setAmount(amount);
    transaction.process();
    if (transaction.successful()) {
      dispense(amount);
    }
    return transaction.getStatus();
  }

这种方法可以使用,但是有一个不幸的副作用:支票帐户余额比测试开始时要低,这使得其他测试更加困难。 有解决方法,但是它们都增加了测试的复杂性。 更糟的是,这种方法还需要往返于负责该费用的系统的三个往返行程。

为了解决这个问题,第一步是重构AtmGui以允许我们用模拟事务代替真实事务,如清单4所示(比较粗体源代码以查看我们要更改的内容):

清单4.重构AtmGui
private Status doWithdrawal(Account account, float amount) {
    Transaction transaction = createTransaction();
    transaction.setSourceAccount(account);
    transaction.setDestAccount(myCashAccount());
    transaction.setAmount(amount);
    transaction.process();
    if (transaction.successful()) {
      dispense(amount);
    }
    return transaction.getStatus();
  }
  
  protected Transaction createTransaction() {
    return new Transaction();
  }

回到测试类内部,我们将MockTransaction类定义为成员类,如清单5所示:

清单5.将MockTransaction定义为成员类
private MockTransaction extends Transaction {

    private boolean processCalled = false;

    // override process method so that no real work is done
    public void process() {
      processCalled = true;
      setStatus(Status.SUCCESS);
    }

    public void validate() {
      assertTrue(processCalled);
    }
  }

最后,我们可以重写测试,以便被测试的对象使用MockTransaction类,而不是真实的类,如清单6所示。

清单6.使用MockTransaction类
MockTransaction mockTransaction;

  public void testCheckingWithdrawal() {

    mockTransaction = new MockTransaction();

    AtmGui atm = new AtmGui() {
        protected Transaction createTransaction() {
          return mockTransaction;
        }
    };

    insertCardAndInputPin(atm);

    atm.pressButton("Withdraw");
    atm.pressButton("Checking");
    atm.pressButtons("1", "0", "0", "0", "0");
    assertContains("$100.00", atm.getDisplayContents());
    atm.pressButton("Continue");

    assertEquals(100.00, mockTransaction.getAmount());
    assertEquals(TEST_CHECKING_ACCOUNT, 
mockTransaction.getSourceAccount());
    assertEquals(TEST_CASH_ACCOUNT, 
mockTransaction.getDestAccount());
    mockTransaction.validate();
}

此解决方案产生的测试时间会稍长一些,但仅与被测试类的即时行为有关,而不是与ATM接口之外的整个系统的行为有关。 也就是说,我们不再检查测试帐户的最终余额是否正确; 我们将在单元测试中检查Transaction对象而不是AtmGui对象的功能。

注意:根据其发明者的观点,模拟对象应在其validate()方法内执行其自身的所有验证。 在此示例中,为清楚起见,我们将一些验证保留在测试方法中。 随着您对模拟对象的使用变得越来越自在,您会感觉到委派给模拟对象的验证责任是多少。

内层魔法

在清单6中,我们使用了AtmGui的匿名内部子类来覆盖createTransaction方法。 因为我们只需要重写一个简单的方法,所以这是实现我们目标的一种简洁方法。 如果我们要覆盖多个方法或在许多测试之间共享AtmGui子类,则可能需要创建一个完整的(非匿名)成员类。

我们还使用了一个实例变量来存储对模拟对象的引用。 这是在测试方法和专业化类之间共享数据的最简单方法。 这是可以接受的,因为我们的测试框架不是多线程或可重入的。 (如果是这样,我们将不得不使用synchronized块来保护自己。)

最后,我们将模拟对象本身定义为测试类的私有内部类,这通常是一种便捷的方法,因为将模拟对象放置在使用该模拟对象的测试代码旁边更加容易,而且内部类可以访问实例他们周围阶级的变量。

安全胜过遗憾

因为我们覆盖了工厂方法来编写此测试,所以事实证明,我们不再对原始创建代码(现在位于基类的工厂方法内部)进行任何测试。 添加明确涵盖此代码的测试可能会有所帮助。 这就像调用基类的factory方法并断言返回的对象是正确类型一样简单。 例如:

AtmGui atm = new AtmGui();
    Transaction t = atm.createTransaction();
    assertTrue(!(t instanceof MockTransaction));

注意,相反, assertTrue(t instanceof Transaction)不能满足要求,因为MockTransaction也是一个Transaction

从工厂方法到抽象工厂

在这一点上,您可能会更进一步,将工厂方法替换为成熟的抽象工厂对象,这在Erich Gamma等人的《 设计模式》中有详细介绍。 (请参阅参考资料 )。 实际上,许多方法本来是从工厂对象而不是工厂方法开始的,但是我们很快就放弃了。

将第三种对象类型(角色)引入系统具有一些潜在的缺点:

  1. 它增加了复杂性,而没有相应地增加功能。
  2. 这可能会迫使您将公共接口更改为目标对象。 如果必须传入一个抽象工厂对象,则必须添加一个新的公共构造函数或增变器。
  3. 许多语言在“工厂”概念上都有约定,可能使您误入歧途。 例如,在Java语言中,工厂通常被实现为静态方法。 在这种情况下,这是不合适的。

请记住,此练习的全部目的是使对象更易于测试 。 通常,针对可测试性进行设计可以将对象的API推向更清洁,更模块化的状态。 但这可能太过分了。 测试驱动的设计更改不应污染原始对象的公共接口。

在ATM示例中,就生产代码而言, AtmGui对象仅生成一种类型的Transaction对象(真实类型)。 测试代码希望它产生不同的类型(模拟)。 但是,仅由于测试代码希望它强制公共API容纳工厂对象或抽象工厂是错误的设计。 如果生产代码不需要实例化此协作者的许多类型,则添加该功能将使所产生的设计不必要地难以理解。


翻译自: https://www.ibm.com/developerworks/java/library/j-mocktest/index.html

 类似资料: