使用 NUnit、NSubtitute 的 Web 测试驱动开发 API
TDD for Web API with NUnit, NSubtitute
我仍然对一些 TDD 概念以及如何正确执行它感到困惑。我正在尝试将它用于一个使用 Web API 的新项目。我已经阅读了很多关于它的文章,一些文章建议将 NUnit 作为测试框架并使用 NSubstitute 来模拟存储库。
我不明白的是,使用 NSubstitute 我们可以定义我们想要的预期结果,如果我们想验证我们的代码逻辑,这是否有效?
假设我有一个这样的控制器,使用 Put
和 Delete
方法:
[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出错。
我仍然对一些 TDD 概念以及如何正确执行它感到困惑。我正在尝试将它用于一个使用 Web API 的新项目。我已经阅读了很多关于它的文章,一些文章建议将 NUnit 作为测试框架并使用 NSubstitute 来模拟存储库。
我不明白的是,使用 NSubstitute 我们可以定义我们想要的预期结果,如果我们想验证我们的代码逻辑,这是否有效?
假设我有一个这样的控制器,使用 Put
和 Delete
方法:
[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出错。