以下列方式对ASP.NET MVC代码进行单元测试可能存在哪些问题?

时间:2012-04-27 23:18:24

标签: asp.net-mvc-3 unit-testing

我一直在研究the NuGetGallery中单元测试的方式。我观察到,当测试控制器时,服务类被模拟。这对我来说很有意义,因为在测试控制器逻辑时,我不想担心下面的架构层。在使用这种方法一段时间之后,我注意到当我的服务类发生变化时,我经常在我的控制器测试中修复我的模拟。为了解决这个问题,在没有咨询比我更聪明的人的情况下,我开始编写这样的测试(不用担心,我还没有那么远):

public class PersonController : Controller
{
    private readonly LESRepository _repository;

    public PersonController(LESRepository repository)
    {
        _repository = repository;
    }

    public ActionResult Index(int id)
    {
        var model = _repository.GetAll<Person>()
            .FirstOrDefault(x => x.Id == id);

        var viewModel = new VMPerson(model);
        return View(viewModel);
    }
}

public class PersonControllerTests
{
    public void can_get_person()
    {
        var person = _helper.CreatePerson(username: "John");
        var controller = new PersonController(_repository);
        controller.FakeOutContext();

        var result = (ViewResult)controller.Index(person.Id);
        var model = (VMPerson)result.Model;
        Assert.IsTrue(model.Person.Username == "John");
    }
}

我想这将是集成测试,因为我使用的是真正的数据库(我更喜欢内存)。我通过将数据放入我的数据库开始我的测试(每个测试在一个事务中运行,并在测试完成时回滚)。然后我调用我的控制器,我真的不在乎它如何从数据库中检索数据(通过存储库或服务类)只是要发送到视图的模型必须有我放入数据库的记录,也就是我的断言。这种方法的一个很酷的事情是,很多时候我可以继续添加更多层的复杂性,而无需更改我的控制器测试:

public class PersonController : Controller
{
    private readonly LESRepository _repository;
    private readonly PersonService _personService;

    public PersonController(LESRepository repository)
    {
        _repository = repository;
        _personService = new PersonService(_repository);
    }

    public ActionResult Index(int id)
    {
        var model = _personService.GetActivePerson(id);
        if(model  == null)
          return PersonNotFoundResult();

        var viewModel = new VMPerson(model);
        return View(viewModel);
    }
}

现在我意识到我没有为PersonService创建一个接口并将其传递给我的控制器的构造函数。原因是1)我不打算模拟我的PersonService和2)我觉得我不需要注入我的依赖,因为我的PersonController现在只需要依赖一种PersonService。

我是单位测试的新手,我总是很高兴被证明我错了。请指出为什么我测试我的控制器的方式可能是一个非常糟糕的主意(除了我的测试运行时间明显增加)。

4 个答案:

答案 0 :(得分:5)

嗯。这里有几件事情。

首先,看起来您正在尝试测试控制器方法。太棒了:))

所以这意味着,控制器需要的任何东西都应该被嘲笑。这是因为

  1. 您不想担心该依赖项内发生的事情。
  2. 您可以验证是否已调用/执行依赖项。
  3. 好的,那么让我们来看看你做了什么,我会看看我是否可以重构它以使其更加可测试。

    -REMEMBER-我正在测试 CONTROLLER METHOD ,而不是控制器方法调用/依赖的东西。

    所以这意味着我不关心服务实例或存储库实例(您决定遵循的架构方式)。

    注意:我保持简单,所以我已经删除了很多废话等等。

    接口

    首先,我们需要一个存储库接口。这可以实现为内存中的repo,实体框架repo等。你很快就会明白为什么。

    public interface ILESRepository
    {
        IQueryable<Person> GetAll();
    }
    

    控制器

    在这里,我们使用界面。这意味着使用模拟IRepository或真实实例非常简单和棒极了。

    public class PersonController : Controller
    {
        private readonly ILESRepository _repository;
    
        public PersonController(ILESRepository repository)
        {
           if (repository == null)
           {
               throw new ArgumentNullException("repository");
           }
            _repository = repository;
        }
    
        public ActionResult Index(int id)
        {
            var model = _repository.GetAll<Person>()
                .FirstOrDefault(x => x.Id == id);
    
            var viewModel = new VMPerson(model);
            return View(viewModel);
        }
    }
    

    单元测试

    好的 - 这是神奇的金钱拍摄的东西。 首先,我们创造了一些假人。在这里和我一起工作......我会告诉你我们在哪里使用它。这只是一个无聊,简单的POCO列表。

    public static class FakePeople()
    {
        public static IList<Person> GetSomeFakePeople()
        {
            return new List<Person>
            {
                new Person { Id = 1, Name = "John" },
                new Person { Id = 2, Name = "Fred" },
                new Person { Id = 3, Name = "Sally" },
            }
        }
    }
    

    现在我们有了测试本身。我正在使用xUnit作为我的测试框架,使用moq作为我的模拟。任何框架都可以,这里。

    public class PersonControllerTests
    {
        [Fact]
        public void GivenAListOfPeople_Index_Returns1Person()
        {
            // Arrange.
            var mockRepository = new Mock<ILESRepository>();
            mockRepository.Setup(x => x.GetAll<Person>())
                                       .Returns(
                                    FakePeople.GetSomeFakePeople()
                                              .AsQueryable);
            var controller = new PersonController(mockRepository);
            controller.FakeOutContext();
    
            // Act.
            var result = controller.Index(person.Id) as ViewResult;
    
            // Assert.
            Assert.NotNull(result);
            var model = result.Model as VMPerson;
            Assert.NotNull(model);
            Assert.Equal(1, model.Person.Id);
            Assert.Equal("John", model.Person.Username);
    
            // Make sure we actually called the GetAll<Person>() method on our mock.
            mockRepository.Verify(x => x.GetAll<Person>(), Times.Once());
        }
    }
    

    好的,让我们来看看我做了什么。

    首先,我安排我的废话。我首先创建了ILESRepository的模拟。 然后我说:如果有人曾调用GetAll<Person>()方法,那么......不要 - 真正地命中数据库或文件或其他任何东西......只返回在FakePeople.GetSomeFakePeople()中创建的人员列表

    所以这就是控制器中会发生的......

    var model = _repository.GetAll<Person>()
                           .FirstOrDefault(x => x.Id == id);
    

    首先,我们要求mock使用GetAll<Person>()方法。我只是'设置'来返回一个人的列表..所以我们有一个3 Person个对象的列表。接下来,我们在3个FirstOrDefault(...)对象的列表上调用Person ..它返回单个对象或null,具体取决于id的值。

    多田!这是钱拍的:)

    现在回到单元测试的其余部分。

    我们Act然后我们Assert。那里没什么难的。 对于奖励积分,我verify我们实际上称为GetAll<Person>()方法,在模拟器上...在控制器的Index方法内。这是一个安全调用,以确保我们的控制器逻辑(我们正在测试)是正确的。

    有时,您可能想检查错误的情况,就像传入错误数据的人一样。这意味着您可能永远不会使用模拟方法(这是正确的),因此您verify从未调用它们。

    好的 - 问题,上课?

答案 1 :(得分:2)

即使你不打算模拟一个接口,我强烈建议你不要通过在构造函数中创建对象来隐藏对象的真正依赖关系,你违反了单一责任原则而你正在编写不可测试的代码。

编写测试时最重要的考虑因素是:“编写测试没有神奇的钥匙”。有很多工具可以帮助你编写测试但是真正的努力应该放在编写可测试代码而不是试图 hack 我们现有的代码来编写测试这通常最终是集成测试,而不是单元测试。

在构造函数中创建一个新对象是代码无法测试的第一个重要信号之一。

当我开始编写测试时,这些链接帮助了我很多,让我告诉你,在你开始之后,这将成为你日常工作的一个自然部分,你会喜欢写测试的好处我可以没有想象自己在没有测试的情况下编写代码

清洁代码指南(在Google中使用):http://misko.hevery.com/code-reviewers-guide/

要获取更多信息,请阅读以下内容:

http://misko.hevery.com/2008/09/30/to-new-or-not-to-new/

观看由Misko Hevery播放的视频

http://www.youtube.com/watch?v=wEhu57pih5w&feature=player_embedded

编辑:

Martin Fowler的这篇文章解释了Classic和Mockist TDD方法之间的区别

http://martinfowler.com/articles/mocksArentStubs.html

总结:

  • 经典TDD方法:这意味着在不创建替代或双打(模拟,存根,虚拟变量)的情况下测试所有内容,但Web服务或数据库等外部服务除外。经典测试人员仅使用双打作为外部服务

    • 好处:当您测试时,您实际上正在测试应用程序的布线逻辑和逻辑本身(不是孤立的)
    • 缺点:如果发生错误,您将看到可能有数百个测试失败,并且很难找到负责的代码
  • Mockist TDD方法:遵循Mockist方法的人将孤立地测试所有代码,因为它们将为每个依赖创建双精度

    • 好处:您正在单独测试应用程序的每个部分。如果发生错误,您确切知道它发生的位置,因为只有少数测试会失败,理想情况下只有一个
    • 缺点:你必须将所有依赖项加倍,这会使测试变得更加困难,但你可以使用AutoFixture等工具自动为依赖项创建双精度数

这是关于编写可测试代码的另一篇很棒的文章

http://www.loosecouplings.com/2011/01/how-to-write-testable-code-overview.html

答案 2 :(得分:1)

有一些缺点。

首先,当您的测试依赖于外部组件(如实时数据库)时,该测试不再具有可预测性。它可能由于多种原因而失败 - 网络中断,数据库帐户上的密码更改,丢失一些DLL等。因此,当您的测试突然失败时,您无法立即确定缺陷 的位置。这是数据库问题吗?你班上有些棘手的错误?

如果您只需知道哪个测试失败就可以立即回答该问题,那么您将获得令人羡慕的defect localization质量。

其次,如果 是数据库问题,那么依赖它的所有测试都会立即失败。这可能不是那么严重,因为你可能已经意识到原因是什么,但我保证它会让你慢下来检查每一个。广泛的失败可以掩盖真正的问题,因为你不想在每次50次测试中查看异常。

我知道你想听听除了执行时间之外的其他因素,但这确实很重要。您希望尽可能频繁地运行测试,而较长的运行时则不鼓励这样做。

我有两个项目:一个有600多个测试,运行时间为10秒,一个有40多个测试,运行时间为50秒(这个项目实际上是故意与数据库对话)。我在开发过程中更频繁地运行更快的测试套件很多。猜猜哪一个我觉得更容易合作?

所有这些都表明,测试外部组件是有价值的。只是当你进行单元测试时。集成测试更脆弱,速度更慢。这使他们更加昂贵。

答案 3 :(得分:1)

在单元测试中访问数据库会产生以下后果:

  1. 效果即可。填充数据库并访问它很慢。你拥有的测试越多,等待的时间就越长。如果您使用模拟控制器测试可能会在几毫秒内运行,相比之下,如果它直接使用数据库则为秒。
  2. 复杂性即可。对于共享数据库,您将不得不处理多个代理针对同一数据库运行测试的并发问题。需要配置数据库,需要创建结构,填充数据等。它变得相当复杂。
  3. 覆盖即可。你会发现有些条件几乎不可能在没有嘲笑的情况下进行测试。示例可能包括验证数据库超时时要执行的操作。或者如果发送电子邮件失败该怎么办。
  4. <强>维护即可。对数据库模式的更改(特别是如果它经常发生)将几乎影响使用数据库的每个测试。在你开始进行10次测试时,它可能看起来不多,但考虑到你有2000次测试。您可能还会发现更改业务规则并使测试更加复杂,因为您必须修改数据库中填充的数据以验证业务规则。
  5. 您必须询问测试业务规则是否值得。在大多数情况下,答案可能是“不”。

    我遵循的方法是:

    1. 单元类(控制器,服务层等)通过模拟依赖关系并模拟可能发生的条件(如数据库错误等)。这些测试验证了业务逻辑,其中一个目标是获得尽可能多的决策路径覆盖。使用PEX之类的工具突出显示您从未想过的任何问题。在解决PEX突出显示的一些问题后,您的应用程序将具有多大的强大(和弹性),您会感到惊讶。
    2. 编写数据库测试以验证我正在使用的ORM是否与底层数据库一起使用。 EF和其他ORM对某些数据库引擎(和版本)的问题会让您感到惊讶。这些测试对于调整性能和减少发送到数据库和从数据库发送的查询和数据量也很有用。
    3. 编写自动化浏览器的编码UI测试,并验证系统是否真正有效。在这种情况下,我会事先用一些数据填充数据库。这些测试只是自动完成我手动完成的测试。目的是验证关键部分是否仍然有效。