【问题标题】:Unit testing a class with no return value?对没有返回值的类进行单元测试?
【发布时间】:2010-12-09 02:30:37
【问题描述】:

我在关于这个特定问题的教程中没有找到太多内容..

所以我有一个名为“Job”的类,它具有公共 ctor 和一个公共 Run() 函数。类中的所有内容都是私有的并封装在类中。 (您可能还记得 Testing only the public method on a mid sized class? 上的一篇较早的帖子,回复对我帮助很大)

这个 Run() 方法做了很多事情 - 将一个 excel 文件作为输入,从中提取数据,向第三方数据供应商发送请求,获取结果并将其放入数据库并记录开始/ 工作结束。

此 Job 类在其 run 方法中使用 3 个单独的接口/类,(IConnection 将连接到第三方供应商并发送请求,IParser 将解析结果,IDataAccess 将结果保存到数据库)。所以现在,我的 Run() 方法中唯一真正的逻辑是提取 excel 输入并将其发送到其他类的链中。我创建了 3 个模拟类并在 Job 类 ctor 上使用 DI,一切都很好而且花花公子......

除了 - 我仍然对如何测试我的 Run() 方法有点迷茫 - 因为它是无效的并且不返回任何东西......

在这种情况下,我是否应该在 Run() 方法中添加一个返回值,以返回从 Excel 文件中提取了多少条记录?因为这是现在在该函数中完成的唯一逻辑..这不会在实际代码中处理,但会在单元测试中......这对我来说似乎有点臭 - 但我是一个新手真正的TDD是关心...

第二个问题 - 我应该创建一个名为 IExcelExtractor 的第四个类,它为我做这个逻辑吗?还是这有点阶级爆炸??

即使我做了后者,如果我的 Run() 函数返回 void 并且它的所有工作都是由实际上什么都不做的模拟对象执行的,我将如何测试我的 Run() 函数?我可以理解我的函数是否有一个有意义的返回值......但在这种情况下,我很困惑。

非常感谢您阅读了这一切,如果您能做到这一点。

【问题讨论】:

  • 吹毛求疵:您的 IDataAccess 不应该被称为 IPersistResults 或类似的名称吗???
  • IDataAccess 也会执行 Saves() 和 Selects()... 术语 Persist 的意思是“保存”但没有 Select...?

标签: unit-testing tdd mocking


【解决方案1】:

您在描述is often called behavior verification(与状态验证相反)。它有它的支持者和反对者,但对于几个类别的课程,如果你想进行单元测试,它是唯一的游戏。

要对行为仅限于与协作者交互的类进行单元测试,您通常会传递模拟协作者对象,这些对象的检测方式允许您验证其方法是否以您期望的方式被调用。

如果您要为您在问题中提到的类手动执行此操作(糟糕!),您可以创建一个实现 IParserMockParser 类,并添加记录是否以及如何调用其方法的属性。

最好使用模拟框架来动态创建模拟,指定对它们的期望,并验证这些期望。

这些天我一直在使用NMock2,测试看起来像这样:

// 'mockery' is the central framework object and Mock object factory
IParser mockParser   = mockery.NewMock<IParser>();

// Other dependencies omitted
Job     job          = new Job(mockParser);

// This just ensures this method is called so the return value doesn't matter
Expect.Once.On(mockParser).
    .Method("Parse").
    .WithAnyArguments().
    .Will(Return.Value(new object()));

job.Run();
mockery.VerifyAllExpectationsHaveBeenMet();

【讨论】:

  • 这太棒了!在意识到我的 Mock 对象开始变得比它们实际的非模拟对象更复杂之后,我下载了 NMock 并使用这篇文章作为指南。这也更容易、更简单、更防弹。对于团队来说,第一次尝试可能会很费劲,但我喜欢挑战 =)。再次感谢大家..
  • 顺便说一句,我确实有一个问题 - mockery.VerifyallExpectationsHaveBeenMet() 做什么???我在我的代码中看到,当预期结果或参数关闭时,在单元测试中引发异常。那么最后的调用在做什么呢?
  • 很高兴听到你喜欢它!更重要的是,我认为一些新的 mock 框架(Rhino、Moq 等)甚至更好,但它们仅适用于 3.5 及更高版本的框架。至于在你的模拟上配置期望并验证它们,这是一个相当大的话题。当我试图弄清楚时,这个对我早期问题的回答对我非常有帮助:stackoverflow.com/questions/1107217/…。使用 NMock,当您使用 Verify... 方法时,您必须确保您已告知 Mockery 对其管理的每个模拟对象的每次调用。
  • 噢!我想通了=)。 NMock 将在“运行时”验证期望/参数,并且 VeryifyAllExpectationsHaveBeenMet() 将确保您为模拟框架指定的任何内容确实被调用。由于我正在执行“反向”TDD,因此 Verify() 调用永远不会失败,直到我注释掉我的一个调用并保留 Expect() 方法然后看着它失败 =)
【解决方案2】:

当您注入模拟时,您将向 Run 类的构造函数传递一个测试类,您将询问测试是否通过。例如,您可以根据您在构造函数中传递的 excel 文件测试 IParser 模拟是否收到了正确的请求。您可以通过您自己的类来执行此操作,并在其中收集结果并测试它收集的内容,或者您​​可以通过一个模拟框架来执行此操作,该框架为您提供了在不构建类的情况下表达此类测试的方法。

我看到你用 tdd 标记了你的问题,但在真正的 tdd 中你并没有真正得到这个问题(你有,但问的不同),因为你首先构建了测试,它定义了接口,而不是构建类界面然后思考你将如何测试这个东西。测试的需求推动了设计。您仍然使用相同的技术(在这种情况下可能最终会采用相同的设计),但问题会有所不同。

【讨论】:

  • 我添加了“单元测试”标签以更准确地表达您所说的内容。
【解决方案3】:

您提到您在自包含类中使用了 3 个类/接口的模拟实现...

为什么不创建一些已知值以从您的模拟 IConnection 返回,只需将所有这些值通过您的模拟 IParser,并将它们存储在您的模拟 IDataAccess 中 - 然后在测试中检查以查看模拟 IDataAccess 中的结果是否匹配运行 run() 方法后来自模拟 IConnection 的输入的预期结果?

编辑添加示例 -

应用程序接口/类:

public interface IConnection {
    public List<Foo> findFoos();
}

public interface IParser {
    public List<Foo> parse(List<Foo> originalFoos);
}

public interface IDataAccess {
    public void save(List<Foo> toSave);
}

public class Job implements Runnable {
    private IConnection connection;
    private IParser parser;
    private IDataAccess dataAccess;

    public Job(IConnection connection, IParser parser, IDataAccess dataAccess) {
        this.connection = connection;
        this.parser = parser;
        this.dataAccess = dataAccess;
    }

    public void run() {
        List<Foo> allFoos = connection.findFoos();
        List<Foo> someFoos = parser.parse(allFoos);
        dataAccess.save(someFoos);
    }
}

模拟/测试类:

public class MockConnection implements IConnection {
    private List<Foo> foos;

    public List<Foo> findFoos() {
        return foos;
    }

    public void setFoos(List<Foo> foos) {
        this.foos = foos;
    }
}

public class MockParser implements IParser {

    private int[] keepIndexes = new int[0];

    public List<Foo> parse(List<Foo> originalFoos) {
        List<Foo> parsedFoos = new ArrayList<Foo>();
        for (int i = 0; i < originalFoos.size(); i++) {
            for (int j = 0; j < keepIndexes.length; j++) {
                if (i == keepIndexes[j]) {
                    parsedFoos.add(originalFoos.get(i));
                }
            }
        }
        return parsedFoos;
    }

    public void setKeepIndexes(int[] keepIndexes) {
        this.keepIndexes = keepIndexes;
    }
}

public class MockDataAccess implements IDataAccess {
    private List<Foo> saved;

    public void save(List<Foo> toSave) {
        saved = toSave;
    }

    public List<Foo> getSaved() {
        return saved;
    }
}

public class JobTestCase extends TestCase {

    public void testJob() {
        List<Foo> foos = new ArrayList<Foo>();
        foos.add(new Foo(0));
        foos.add(new Foo(1));
        foos.add(new Foo(2));
        MockConnection connection = new MockConnection();
        connection.setFoos(foos);
        int[] keepIndexes = new int[] {1, 2};
        MockParser parser = new MockParser();
        parser.setKeepIndexes(keepIndexes);
        MockDataAccess dataAccess = new MockDataAccess();
        Job job = new Job(connection, parser, dataAccess);
        job.run();
        List<Foo> savedFoos = dataAccess.getSaved();
        assertTrue(savedFoos.length == 2);
        assertTrue(savedFoos.contains(foos.get(1)));
        assertTrue(savedFoos.contains(foos.get(2)));
        assertFalse(savedFoos.contains(foos.get(0)));
    }
}

【讨论】:

  • 感谢 Nate 和其他所有人。这似乎是最简单和最明显的答案 - 但同样,我的 IDataAccess 只有一个 void() 函数,它将结果保存到数据库中。我是否必须向我的 IDataAccess 类添加额外的方法(如 returnRecentInsertCount()),然后为我的模拟 IDataAccess 对象的实例创建一个访问器来检查该值?或者我应该添加功能(有 IDataAccess.Insert() 返回计数,并让 job.Run 将此计数传播到返回值?)再次 - 无效返回/私有成员问题..
  • @dferraro - 你可以这样做(自己添加额外的方法),但如果有任何可用于你的开发环境的 Mocking 框架,你会更好地为你做这件事。 (并且有许多针对最流行语言的免费语言。)
  • 我仍然在努力让这些模拟在没有实际逻辑的情况下正确地相互“交谈”。好的,所以我让 IConnection Mock 只返回一个“假”平面文件,方法是返回一个像 ",,,,,". 之类的字符串。然后我的模拟解析器接受这个字符串......但是......现在呢?他接受一个字符串并返回一个数据表。如果我真的把逻辑放在适当的位置把这个假字符串变成一个数据,我现在正在重写我的实际解析函数,它不再是一个模拟 =(...
  • 请注意 - 虽然我完全看到了使用模拟框架的优势并且并不“害怕”它们 - 我故意避免使用它们,因为我正在使用这个项目进行研发和培训目的和团队需要采取婴儿步骤......
  • 我认为要记住的重要一点是 - 根据您的描述 - Job.Run 不需要很多有趣的单元测试。您可能会编写测试(例如)来验证它在您的 IConnection 返回正确结果时是否符合您的预期(也就是说,您将测试它是否使用这些结果调用 IParser)和一个测试来验证它是否符合您的预期它返回 null (从您所写的内容来看,正确的行为并不明显)。这不是很令人兴奋,但 Job.Run 的目的只是为了协调协作者的行为,而不是执行逻辑本身。
【解决方案4】:

TDD 的思想基本上是通过坚持它,您将编写易于测试的代码,因为您首先针对缺乏实现的接口编写测试,然后编写代码来进行测试经过。看来您在测试之前已经编写了 Job 类。

我发现您可以更改 Job.Run 实现,在这种情况下,如果您希望代码可测试,您应该对其进行一些操作,以便能够读取您需要测试的值。

【讨论】:

    【解决方案5】:

    如果您的run() 方法唯一要做的就是调用其他对象,那么您可以对其进行测试,但要验证是否调用了模拟。具体如何执行取决于模拟包,但通常您会找到某种“期望”方法。

    不要在你的 run() 方法中编写代码来跟踪它的执行。如果您无法根据其与协作者(模拟)的交互来验证方法的操作,则表明需要重新考虑这些交互。这样做也会使主线代码混乱,增加维护成本。

    【讨论】:

      【解决方案6】:

      我已经问过similar question

      虽然(超越理论)我确实认为某些方法不需要单元测试,只要(并且直到)它们:

      • 不返回任何值
      • 不要更改可以检查的类或系统的内部状态
      • 不要依赖模拟之外的任何其他东西(作为输入或输出)

      如果它们的功能(即调用序列)至关重要,您将必须验证是否满足内部功能。这意味着您必须验证(使用您的模拟)是否已使用正确的参数和正确的顺序(如果重要的话)调用了这些方法。

      【讨论】:

      • 大多数体面的模拟框架允许您通过传入的模拟对象断言调用流是正确的,如果模拟函数没有按正确的顺序调用,则提供使测试失败的机制。
      • @workmad3:没错。我稍微改变了我的答案以说明这一点,以防你明白我认为他们没有......
      【解决方案7】:

      首先,由于您的 run() 方法是一种工作流启动器,并且您需要在工作流中执行几个步骤,我认为您需要多个单元测试,甚至可能拆分现有的将其分成几个较小的部分,每个对应于工作流程中的一个步骤。

      这样,您还将单独测试工作流的每个步骤,如果在任何时候工作流失败,这些较小的单元测试将使您更容易识别有故障的部分(失败的步骤)

      但也许已经是这样了,不知道你们是不是已经有这种划分了。

      不管怎样,回到你的 run() 方法,答案就在你的问题中:

      这个 Run() 方法做了很多事情 - 将一个 excel 文件作为输入,从中提取数据,向第三方数据供应商发送请求,获取结果并将其放入数据库并记录开头/工作结束

      所以你有:

      • 一些输入数据(来自excel文件)

      • 一些“输出”数据,或者更确切地说是 wokflow 的结果。

      为了让你的 run() 成功,你需要检查:

      a) 请求已发送给第三方和/或已收到结果。我不知道哪些会更容易检查,但至少您可以记录请求/响应并检查日志(在单元测试中)以了解正在执行的操作。这将确保执行整个工作流程(我们可以想象在工作流程结束时数据库中存在正确数据的场景,但不是因为运行正常,而是因为数据已经存在或沿着那些行 - 例如,如果测试前的清除不会删除某些数据)

      b) 检查数据库中是否有正确的值(关于输入值)作为工作流程的结果在适当的位置插入/更新。

      c)您甚至可以检查您提到的日志(作业的开始/结束)以了解两个操作之间延迟的有效性(如果您知道它的工作速度不能超过 10 秒,如果您日志说工作在 1 秒内完成,你会知道出了点问题......)


      编辑:作为上述 a) 之前的第一个测试,您可能还想检查输入数据,因为您可能会想象那里也有错误(缺少 excel 文件,或者内容已更改,所以您输入错误等)

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2022-12-18
        • 2013-04-03
        • 2019-11-17
        • 1970-01-01
        • 2011-06-25
        • 1970-01-01
        • 2014-10-02
        • 2015-06-22
        相关资源
        最近更新 更多