使用 NUnit、NSubtitute 的 Web 测试驱动开发 API

TDD for Web API with NUnit, NSubtitute

我仍然对一些 TDD 概念以及如何正确执行它感到困惑。我正在尝试将它用于一个使用 Web API 的新项目。我已经阅读了很多关于它的文章,一些文章建议将 NUnit 作为测试框架并使用 NSubstitute 来模拟存储库。

我不明白的是,使用 NSubstitute 我们可以定义我们想要的预期结果,如果我们想验证我们的代码逻辑,这是否有效?

假设我有一个这样的控制器,使用 PutDelete 方法:

[BasicAuthentication]
public class ClientsController : BaseController
{
   // Dependency injection inputs new ClientsRepository
   public ClientsController(IRepository<ContactIndex> clientRepo) : base(clientRepo) { }

 [HttpPut]
    public IHttpActionResult PutClient(string accountId, long clientId, [FromBody] ClientContent data, string userId = "", string deviceId = "", string deviceName = "")
    {
        var result = repository.UpdateItem(new CommonField()
        {
            AccountId = accountId,
            DeviceId = deviceId,
            DeviceName = deviceName,
            UserId = userId
        }, clientId, data);

        if (result.Data == null)
        {
            return NotFound();
        }

        if (result.Data.Value != clientId)
        {
            return InternalServerError();
        }

        IResult<IDatabaseTable> updatedData = repository.GetItem(accountId, clientId);

        if (updatedData.Error)
        {
            return InternalServerError();
        }

        return Ok(updatedData.Data);
    }

    [HttpDelete]
    public IHttpActionResult DeleteClient(string accountId, long clientId, string userId = "", string deviceId = "")
    {
        var endResult = repository.DeleteItem(new CommonField()
        {
            AccountId = accountId,
            DeviceId = deviceId,
            DeviceName = string.Empty,
            UserId = userId
        }, clientId);

        if (endResult.Error)
        {
            return InternalServerError();
        }

        if (endResult.Data <= 0)
        {
            return NotFound();
        }

        return Ok();
    }

}

然后我创建了一些这样的单元测试:

[TestFixture]
    public class ClientsControllerTest
    {
        private ClientsController _baseController;
        private IRepository<ContactIndex> clientsRepository;
        private string accountId = "account_id";
        private string userId = "user_id";
        private long clientId = 123;
        private CommonField commonField;

        [SetUp]
        public void SetUp()
        {
            clientsRepository = Substitute.For<IRepository<ContactIndex>>();
            _baseController = new ClientsController(clientsRepository);
            commonField = new CommonField()
            {
                AccountId = accountId,
                DeviceId = string.Empty,
                DeviceName = string.Empty,
                UserId = userId
            };
        }

        [Test]
        public void PostClient_ContactNameNotExists_ReturnBadRequest()
        {
            // Arrange
            var data = new ClientContent
            {
                shippingName = "TestShippingName 1",
                shippingAddress1 = "TestShippingAdress 1"
            };

            clientsRepository.CreateItem(commonField, data)
                .Returns(new Result<long>
                {
                    Message = "Bad Request"
                });

            // Act
            var result = _baseController.PostClient(accountId, data, userId);

            // Asserts
            Assert.IsInstanceOf<BadRequestErrorMessageResult>(result);
        }

        [Test]
        public void PutClient_ClientNotExists_ReturnNotFound()
        {
            // Arrange
            var data = new ClientContent
            {
                contactName = "TestContactName 1",
                shippingName = "TestShippingName 1",
                shippingAddress1 = "TestShippingAdress 1"
            };

            clientsRepository.UpdateItem(commonField, clientId, data)
                .Returns(new Result<long?>
                {
                    Message = "Data Not Found"
                });

            var result = _baseController.PutClient(accountId, clientId, data, userId);
            Assert.IsInstanceOf<NotFoundResult>(result);
        }

        [Test]
        public void PutClient_UpdateSucceed_ReturnOk()
        {
            // Arrange
            var postedData = new ClientContent
            {
                contactName = "TestContactName 1",
                shippingName = "TestShippingName 1",
                shippingAddress1 = "TestShippingAdress 1"
            };

            var expectedResult = new ContactIndex() { id = 123 };

            clientsRepository.UpdateItem(commonField, clientId, postedData)
                .Returns(new Result<long?> (123)
                {
                    Message = "Data Not Found"
                });

            clientsRepository.GetItem(accountId, clientId)
                .Returns(new Result<ContactIndex>
                (
                    expectedResult
                ));

            // Act
            var result = _baseController.PutClient(accountId, clientId, postedData, userId)
                .ShouldBeOfType<OkNegotiatedContentResult<ContactIndex>>();

            // Assert
            result.Content.ShouldBe(expectedResult);
        }

        [Test]
        public void DeleteClient_ClientNotExists_ReturnNotFound()
        {
            clientsRepository.Delete(accountId, userId, "", "", clientId)
                .Returns(new Result<int>()
                {
                    Message = ""
                });

            var result = _baseController.DeleteClient(accountId, clientId, userId);

            Assert.IsInstanceOf<NotFoundResult>(result);
        }

        [Test]
        public void DeleteClient_DeleteSucceed_ReturnOk()
        {
            clientsRepository.Delete(accountId, userId, "", "", clientId)
                .Returns(new Result<int>(123)
                {
                    Message = ""
                });

            var result = _baseController.DeleteClient(accountId, clientId, userId);

            Assert.IsInstanceOf<OkResult>(result);
        }
    }

查看上面的代码,我是否正确编写了单元测试?我觉得我不确定它将如何验证我的控制器中的逻辑。

如果有任何需要澄清的地方,请询问更多信息。

首先,在使用 TDD 编码时,必须尽可能地实现最小的功能。大约三行代码(不包括括号和签名) The function should have only a purpose.例如:名为 GetEncriptedData 的函数应该调用另外两个方法 GetData 和 EncryptData,而不是获取数据并对其进行加密。如果您的 tdd 做得很好,那么获得该结果应该不是问题。当函数太长时,测试就毫无意义,因为它们无法真正涵盖您的所有逻辑。我的测试使用 having when then 逻辑。例如:HavingInitialSituationA_WhenDoingB_ThenShouldBecomeC 是测试的名称。 您会在测试中找到代表这三个部分的三个代码块。还有更多。在做 tdd 时,你应该总是一次做一个步骤。如果您希望您的函数为 return 2,请进行测试以验证它是否为 return 两个,并使您的函数字面意思为 return 2。之后您可能需要一些条件并测试它们在其他测试用例中,您所有的测试都应该放在最后。 TDD 是一种完全不同的编码方式。你做了一个测试,它失败了,你做了必要的代码让它通过,然后你做了另一个测试,它失败了……这是我的经验,我实施 TDD 的方式告诉我你错了。但这是我的观点。希望对你有帮助。

如果您实际发布的代码真实反映了测试与代码的比率,那么您似乎没有遵循 TDD 方法。核心概念之一是您不编写未经测试的代码。这意味着作为一项基本规则,您需要对代码中的每个分支至少进行一次测试,否则就没有理由编写该分支。

查看您的 DeleteClient 方法有三个分支,因此该方法应该至少有三个测试(您只发布了两个)。

// Test1 - If repo returns error, ensure expected return value
DeleteClient_Error_ReturnsInternalError

// Test2 - If repo returns negative data value, ensure expected return value
DeleteClient_NoData_ReturnsNotFound

// Test3 - If repo returns no error, ensure expected return
DeleteClient_Success_ReturnsOk

您可以使用 NSubtitute 将您的代码重定向到这些不同的路径,以便对它们进行测试。因此,要重定向到 InternalError 分支,您可以像这样设置替代品:

clientsRepository.Delete(Args.Any<int>(), Args.Any<int>(), 
                         Args.Any<string>(), Args.Any<string>(), 
                         Args.Any<int>())
            .Returns(new Result<int>()
            {
                Error = SomeError;
            });

在不了解 IRepository 接口的情况下,很难 100% 准确地了解 NSubstitute 设置,但基本上,以上内容是在使用给定参数调用替代方法的 Delete 时说的类型 (int,int,string,string,int) 替代品应该 return 一个 Error 设置为 SomeError 的值(这是 InternalError 分支的触发器逻辑)。然后你会断言,当调用被测系统时,它 returns InternalServerError.

您需要为每个逻辑分支重复此操作。不要忘记,您需要将替代设置为 return 所有适当的值才能到达每个逻辑分支。因此,要到达 ReturnsNotFound 分支,您需要将存储库 return NoError 设置为负数据值。

我在上面说过,每个逻辑分支至少需要一个测试。这是最低要求,因为您还需要测试其他内容。在上面的替代设置中,您会注意到我正在使用 Args.Any<int> 等。那是因为对于上面的测试感兴趣的行为,是否将正确的值传递给存储库并不重要或不。这些测试正在测试受存储库 return 值影响的逻辑流。为了完成测试,您还需要确保将正确的值传递到存储库。根据您的方法,您可能对每个参数都有一个测试,或者您可能有一个测试来验证对存储库的调用中的所有参数。

要验证所有参数,以 ReturnsInternalError 测试为基础,您只需添加一个验证调用来验证参数:

clientsRepository.Received().Delete(accountId, userId, "", "", clientId);

我使用 ReturnsInternalError 测试作为基础,因为在验证调用之后,我想尽快退出被测方法,在本例中是 return出错。