没有返回值的类如何进行单元测试?

31

我在教程中没有找到与此特定问题相关的内容。

所以我有一个名为'Job'的类,其中具有公共构造函数和单个公共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()方法-因为它是void且不返回任何内容...

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

第二个问题-我应该创建一个名为IExcelExtractor的第四个类,它可以为我完成这个逻辑吗? 还是这有点爆炸性的类?

即使我采用后者,如果Run()函数返回void并且所有工作都由什么也不做的模拟对象执行,我该如何测试我的Run()函数? 如果我的函数具有有意义的返回值,我可以理解...但在这种情况下,我有点困惑。

非常感谢您阅读到这里。


吹毛求疵:你的IDataAccess不应该被称为IPersistResults,或者类似的东西吗? - Mitch Wheat
IDataAccess还会执行Saves()和Selects()操作...术语“Persist”意味着仅“保存”,但没有查询操作...? - dferraro
7个回答

24
你所描述的通常被称为行为验证(与状态验证相对)。它有支持者和反对者,但对于几类类别的课程来说,如果您想进行单元测试,它是唯一的选择。
为了对一个行为仅限于与协作者交互的类进行单元测试,您通常会传递模拟协作者对象,这些对象被配置以允许您验证其方法是否按照您的期望被调用。
如果您要手动执行此操作(呕吐!)针对您在问题中提到的类,您可能会创建一个MockParser类,该类实现IParser并添加属性,记录其方法是否被调用以及如何调用。
最好使用mocking框架来动态创建mock对象,指定期望,并验证这些期望。
我最近一直在使用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();

1
这太棒了!在意识到我的虚拟对象开始变得比它们的实际非模拟对应物更加复杂之后,我下载了NMock,并将此帖子用作指南。这样做要简单得多、更加简洁和更加可靠。也许对于团队来说,在第一次尝试时有点吃力,但我喜欢挑战 =)再次感谢大家。 - dferraro
顺便问一句,mockery.VerifyallExpectationsHaveBeenMet()是什么意思?我在我的代码中看到,在单元测试期间,当预期的结果或参数不正确时,会抛出异常。那么最后的调用是做什么的呢? - dferraro
1
很高兴听到你喜欢它!此外,我认为一些新的模拟框架(如Rhino、Moq等)甚至更好,但它们只适用于3.5框架及以上版本。至于在模拟中配置期望并验证它们,这是一个相当大的话题。当我试图弄清楚时,我之前提出的问题的答案对我非常有帮助:https://dev59.com/gnNA5IYBdhLWcg3wEZaT#1107240。使用NMock时,当您使用Verify...方法时,您必须确保已告知Mockery管理的每个模拟对象的每个调用。 - Jeff Sternal
糟糕!我想通了 =)。NMock将在“运行时”验证期望/参数,并且VerifyAllExpectationsHaveBeenMet()将确保您实际上调用了模拟框架中指定的任何内容。由于我正在进行“反向”TDD,因此Verify()调用从未失败,直到我注释掉其中一个调用并保留Expect()方法,然后看它失败 =)。 - dferraro

5
当您注入一个模拟对象时,您会将测试类传递给Run类的构造函数,并询问测试是否通过。例如,您可以测试IParser模拟对象是否根据您在构造函数中传递的Excel文件得到了正确的请求。您可以通过自己的类来实现这一点,并在其中收集结果并测试其收集的内容,或者您可以通过模拟框架来实现这一点,该框架为您提供了表达此类测试的方式,而无需构建类。
我看到您标记了您的问题为TDD,但在真正的TDD中,您不会真正遇到这个问题(您会遇到,但是提问方式不同),因为您首先编写测试用例,然后定义接口,而不是先构建类接口,然后再考虑如何测试这个东西。测试驱动设计。您仍然使用相同的技术(在这种情况下可能最终得到相同的设计),但问题的提出方式可能会有所不同。

我添加了“单元测试”标签,以使您所说的更加精确。 - Robert Koritnik

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()),然后创建一个mocked IDataAccess对象实例的访问器,以检查该值?还是应该添加功能(使IDataAccess.Insert()返回计数,并使job.Run将此计数传播到返回值中?)再次提到,void返回/私有成员问题... - dferraro
@dferraro - 你可以这样做(自己添加额外的方法),但是如果你的开发环境中有可用的模拟框架,最好使用它们来为你完成这项工作。(对于大多数流行的编程语言,有许多免费的模拟框架可供选择。) - Jeff Sternal
我仍然在努力让模拟对象之间的“对话”正确进行,而不进行实际逻辑。好吧,所以我让 IConnection Mock 只返回一个“伪造”的平面文件,即返回一个字符串,如“, , , , , ”。我的模拟解析器接受此字符串……但是……现在怎么办?他接受一个字符串并返回一个数据表。如果我真的放置了将这个虚假字符串转换为数据表的逻辑,那么我现在正在重写我的实际解析函数,它不再是一个模拟对象 = (...) - dferraro
请注意 - 虽然我完全看到使用模拟框架的优势,也不会“害怕”它们 - 但我故意避免使用它们,因为我正在将此项目用于研发和培训目的,团队需要逐步学习... - dferraro
我认为需要记住的一件重要事情是,根据您的描述,Job.Run 不需要太多有趣的单元测试。您可能会编写测试(例如),以验证当 IConnection 返回正确结果时它是否按预期调用 IParser(也就是说,您将测试它是否使用这些结果调用 IParser),并且还会验证当它返回 null 时是否按预期执行(从您所写的内容中无法确定正确的行为)。这不是非常令人兴奋,但 Job.Run 的目的只是协调协作者的行为,而不是执行逻辑本身。 - Jeff Sternal
显示剩余2条评论

2

TDD的基本理念是通过遵循它,你将编写易于测试的代码。因为你首先针对一个缺乏实现的接口编写测试,然后再编写代码使测试通过。看起来你已经先编写了Job类,而不是先编写测试。

我发现你可以更改Job.Run的实现方式。如果你想让代码可测试,你应该对其进行一些更改,以便能够读取需要测试的值。


1

如果你的run()方法唯一要做的事情就是调用其他对象,那么你可以通过验证模拟对象是否被调用来测试它。具体如何实现取决于模拟包,但通常你会找到某种形式的“expect”方法。

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


1

我已经问了一个类似的问题

尽管(经验胜于理论),但我认为只要以下条件满足,有些方法就不需要单元测试:

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

如果它们的功能(即调用序列)很重要,则必须验证是否符合内部功能。这意味着您必须使用您的模拟来验证这些方法是否已使用正确的参数和正确的顺序进行调用(如果这很重要)。


大多数体面的模拟框架都允许您通过提供机制来检查调用流是否正确,以便在未按正确顺序调用模拟函数时失败测试。 - workmad3
@workmad3:没错。我稍微修改了一下我的答案,以便让你明白我并不认为他们不... - Robert Koritnik

1
首先,由于您的 run() 方法类似于工作流程启动器,并且您需要在工作流程中执行多个步骤,我认为您需要不止一个单元测试,甚至需要将现有类拆分成几个较小的类,每个类对应于工作流程中的一步。
这样,您还将单独测试工作流程的每个步骤,如果在任何时刻工作流程失败,这些较小的单元测试将使您更容易地识别故障部分(失败的步骤)。
但也许已经是这种情况了,我不知道是否已经有了这种划分。
无论如何,回到您的 run() 方法,答案就在您的问题中:
这个 Run() 方法做了很多事情 - 以 Excel 文件作为输入,从中提取数据,向第三方数据供应商发送请求,获取结果并将其放入数据库中并记录作业的开始/结束时间。
所以,您有:
一些输入数据(来自 Excel 文件)
一些“输出”数据或者说是工作流程的结果。
为了使您的 run() 方法成功,您需要检查:

a) 请求已发送给第三方和/或已收到结果。我不知道哪个更容易检查,但至少您可以记录请求/响应并在单元测试中检查操作是否被执行。这将确保整个工作流程得以执行(我们可以想象这样的情况:在工作流程结束时数据库中存在正确的数据,但不是因为运行正确,而是因为数据已经存在或类似的原因 - 如果测试之前的清除未删除某些数据,例如)

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

c) 您甚至可以检查您提到的日志(作业开始/结束)以验证两个操作之间的延迟的有效性(如果您知道它不能比10秒快,如果您的日志显示1秒完成作业,您将知道出了问题...)


编辑:在进行上述步骤a)之前,您可能需要先进行第一次测试并检查输入数据,因为您可能会遇到错误(例如缺少Excel文件或内容已更改导致输入错误等)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接