编写工作单元测试时,多长时间查看一次它的代码,这是...不好吗? 您的想法是:“这是一个测试,让我们这样吧……”。 否,%用户名%,所以不要离开它。 测试是提供代码支持的系统的重要组成部分,这一部分也得到支持非常重要。 不幸的是,我们没有很多方法可以确保这一点(我们不会为测试编写测试),但是仍然有很多方法。

在我们的Dodo DevSchool开发人员学校中,我们强调了以下一项进行良好测试的标准:
- 重现性:在相同的代码和输入上运行测试始终会得到相同的结果;
- 重点:测试下降的原因只有一个;
- 易懂性:嗯,这很清楚。 :)
您如何看待这些标准方面的测试?
[Fact] public void AcceptOrder_Successful() { var ingredient1 = new Ingredient("Ingredient1"); var ingredient2 = new Ingredient("Ingredient2"); var ingredient3 = new Ingredient("Ingredient3"); var order = new Order(DateTime.Now); var product1 = new Product("Pizza1"); product1.AddIngredient(ingredient1); product1.AddIngredient(ingredient2); var orderLine1 = new OrderLine(product1, 1, 500); order.AddLine(orderLine1); var product2 = new Product("Pizza2"); product2.AddIngredient(ingredient1); product2.AddIngredient(ingredient3); var orderLine2 = new OrderLine(product2, 1, 650); order.AddLine(orderLine2); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); }
对我来说-非常糟糕。
这令人难以理解:例如,我什至无法分配Arrange,Act和Assert块。
无法播放:使用DateTime.Now属性。 最后,它没有重点,因为 下降的原因有两个:检查对两个存储库方法的调用。
此外,尽管测试的命名超出了本文的范围,但我仍然要注意名称:由于具有这样的一组负面特性,因此很难以这样的方式来表达它:在查看测试名称时,外部人员会立即理解为什么该测试通常出现在项目中。
如果您不能简明扼要地命名测试,则说明测试出了点问题。
由于测试难以理解,因此让我们告诉您其中发生了什么:
- 成分被创建。
- 从成分中创建产品(比萨饼)。
- 从产品创建订单。
- 创建了一个存储库是湿的服务。
- 订单被传递到服务的AcceptOrder方法。
- 验证已调用了相应存储库的Add和ReserveIngredients方法。
那么我们如何使这项测试更好呢? 您只需要尝试将真正重要的内容留在测试主体中即可。 因此,像Martin Fowler和Rebecca Parsons这样的聪明人想出了
DSL(领域特定语言) 。 在这里,我将讨论Dodo所使用的DSL模式,以确保我们的单元测试既柔软又丝滑,并使开发人员每天都充满信心。
计划是这样的:首先,我们将使该测试易于理解,然后我们将致力于可重复性,并最终将其重点突出。 我们开车...
成分的处置(预定义的域对象)
让我们从订单创建块开始。 排序是中央域实体之一。 如果我们可以用这样一种方式来描述顺序,那就是很酷的方法,即使即使是那些不知道如何编写代码但了解域逻辑的人也可以理解我们正在创建哪种顺序。 为此,首先,我们需要放弃使用抽象的“ Ingredient1”和“ Pizza1”来替换为真实的食材,比萨饼和其他领域对象。
最优化的第一个候选人是成分。 使用它们,一切都很简单:它们不需要任何自定义,只需调用构造函数即可。 将它们放在单独的容器中并命名它们就足够了,以便领域专家清楚地知道:
public static class Ingredients { public static readonly Ingredient Dough = new Ingredient("Dough"); public static readonly Ingredient Pepperoni = new Ingredient("Pepperoni"); public static readonly Ingredient Mozzarella = new Ingredient("Mozzarella"); }
代替了完全疯狂的成分1,成分2和成分3,我们得到了面团,意大利辣香肠和马苏里拉奶酪。
将预定义的域对象用于常用的域实体。
产品生成器
下一个领域实体是产品。 它们使一切都变得有些复杂:每种产品都包含几种成分,使用前我们必须将它们添加到产品中。
在这里,良好的旧Builder模式将派上用场。 这是该产品的构建版本:
public class ProductBuilder { private Product _product; public ProductBuilder(string name) { _product = new Product(name); } public ProductBuilder Containing(Ingredient ingredient) { _product.AddIngredient(ingredient); return this; } public Product Please() { return _product; } }
它由参数化的构造函数,自定义的
Containing
方法和终端的
Please
方法组成。 如果您不喜欢该代码,可以将
Now
替换为
Now
。 该构建器隐藏了配置对象的复杂构造函数和方法调用。 该代码变得更加清晰和易于理解。 好的方法是,构建器应该简化对象的创建,以便领域专家可以清楚地看到代码。 对于需要在开始工作之前进行配置的对象使用生成器尤其值得。
产品构建器将允许您创建如下设计:
var pepperoni = new ProductBuilder("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please();
构建可帮助您创建需要自定义的对象。 即使配置由一行组成,也可以考虑创建一个构建器。
对象母亲
尽管产品的创建变得更加体面的事实,但是
new ProductBuilder
的设计师看起来仍然很丑陋。 使用ObjectMother(父亲)模式对其进行修复。
模式很简单,只有5个戈比:我们创建一个静态类,并将所有构建器收集到其中。
public static class Create { public static ProductBuilder Product(string name) => new ProductBuilder(name); }
现在您可以这样写:
var pepperoni = Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please();
发明ObjectMother是为了声明式创建对象。 此外,它还有助于将新开发人员引入该领域,例如 在写“
Create
IDE”一词时,它会告诉您可以在此域中创建什么。
在我们的代码中,ObjectMother有时称为Not
Create
,但是
Given
。 我都喜欢这两种选择。 如果您还有其他想法,请在评论中分享。
要以声明方式创建对象,请使用ObjectMother。 该代码将变得更加简洁,新开发人员更容易深入研究领域。
产品清除
它已经变得更好,但是产品仍然有增长的空间。 我们提供的产品数量有限,并且像成分一样,可以将它们收集在单独的类中,而无需为每个测试初始化:
public static class Pizza { public static Product Pepperoni => Create.Product("Pepperoni") .Containing(Ingredients.Dough) .Containing(Ingredients.Pepperoni) .Please(); public static Product Margarita => Create.Product("Margarita") .Containing(Ingredients.Dough) .Containing(Ingredients.Mozzarella) .Please(); }
在这里,我称该容器不是
Products
,而是
Pizza
。 此名称有助于阅读测试。 例如,它有助于消除“意大利辣香肠是比萨饼还是香肠?”之类的问题。
尝试使用真实的域对象,而不是Product1之类的替代对象。
订单的生成器(后面的示例)
现在,我们使用描述的模式来创建订单构建器,但现在让我们从不希望从构建器中获取信息,而是从我们希望收到的信息中获取信息。 这就是我要创建订单的方式:
var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please();
我们怎样才能做到这一点? 显然,我们需要订单和订单行的构建器。 有了订购者的建造商,一切就变得清晰起来。 这是:
public class OrderBuilder { private DateTime _date; private readonly List<OrderLine> _lines = new List<OrderLine>(); public OrderBuilder Dated(DateTime date) { _date = date; return this; } public OrderBuilder With(OrderLine orderLine) { _lines.Add(orderLine); return this; } public Order Please() { var order = new Order(_date); foreach (var line in _lines) { order.AddLine(line); } return order; } }
但是使用
OrderLine
情况就更有趣了:首先,此处未调用终端机的Please方法,其次,不是通过静态
Create
来提供对构建器的访问,而不是通过构建器本身的构造器来访问。 我们将使用
implicit operator
解决第一个问题,我们的构建器将如下所示:
public class OrderLineBuilder { private Product _product; private decimal _count; private decimal _price; public OrderLineBuilder Of(decimal count, Product product) { _product = product; _count = count; return this; } public OrderLineBuilder For(decimal price) { _price = price; return this; } public static implicit operator OrderLine(OrderLineBuilder b) { return new OrderLine(b._product, b._count, b._price); } }
第二种方法将帮助我们了解
Product
类的Extension方法:
public static class ProductExtensions { public static OrderLineBuilder CountOf(this Product product, decimal count) { return Create.OrderLine.Of(count, product) } }
通常,扩展方法是DSL的好朋友。 他们可以从完全地狱的逻辑中做出声明性的,可理解的描述。
使用扩展方法。 只需使用它们。 :)
完成所有这些操作后,我们得到了以下测试代码:
[Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); var service = new PizzeriaService(orderRepositoryMock.Object, ingredientsRepositoryMock.Object); service.AcceptOrder(order); orderRepositoryMock.Verify(r => r.Add(order), Times.Once); ingredientsRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); }
在这里,我们采用了被称为“童话”的方法。 这是您首先想看到的空闲代码,然后尝试包装在DSL中编写的代码的时间。 这样做非常有用-有时您自己无法想象C#的功能。
想象一下,一个魔术仙子来了,并允许您根据需要编写代码,然后尝试包装用DSL编写的所有内容。
创建服务(可测试模式)
现在有了订单,一切都差不多了。 现在是处理存储库中的问题的时候了。 在这里值得一说的是,我们正在考虑的测试本身就是对行为的测试。 行为测试与方法的实现密切相关,如果可能不编写此类测试,则最好不要这样做。 但是,有时它们很有用,有时,您根本无法没有它们。 以下技术有助于准确地编写行为测试,如果您突然意识到要使用它,请首先考虑是否可以以测试状态而不是行为的方式重写测试。
因此,我想确保在我的测试方法中没有单个mok。 为此,我将为
PizzeriaService
创建一个包装器,在其中封装检查方法调用的所有逻辑:
public class PizzeriaServiceTestable : PizzeriaService { private readonly Mock<IOrderRepository> _orderRepositoryMock; private readonly Mock<IIngredientRepository> _ingredientRepositoryMock; public PizzeriaServiceTestable(Mock<IOrderRepository> orderRepositoryMock, Mock<IIngredientRepository> ingredientRepositoryMock) : base(orderRepositoryMock.Object, ingredientRepositoryMock.Object) { _orderRepositoryMock = orderRepositoryMock; _ingredientRepositoryMock = ingredientRepositoryMock; } public void VerifyAddWasCalledWith(Order order) { _orderRepositoryMock.Verify(r => r.Add(order), Times.Once); } public void VerifyReserveIngredientsWasCalledWith(Order order) { _ingredientRepositoryMock.Verify(r => r.ReserveIngredients(order), Times.Once); } }
此类将使我们能够检查方法调用,但是我们仍然需要以某种方式创建它。 为此,我们将使用我们已经知道的构建器:
public class PizzeriaServiceBuilder { public PizzeriaServiceTestable Please() { var orderRepositoryMock = new Mock<IOrderRepository>(); var ingredientsRepositoryMock = new Mock<IIngredientRepository>(); return new PizzeriaServiceTestable(orderRepositoryMock, ingredientsRepositoryMock); } }
目前,我们的测试方法如下:
[Fact] public void AcceptOrder_Successful() { var order = Create.Order .Dated(DateTime.Now) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); service.VerifyReserveIngredientsWasCalledWith(order); }
测试方法调用不是可以使用Testable类的唯一原因。 例如,
在这里,我们的Dima Pavlov将其用于遗留代码的复杂重构。
Testable能够在最困难的情况下节省情况。 对于行为测试,它有助于将难看的调用检查包装到漂亮的方法中。
在这一重要时刻,我们完成了对测试的可理解性的了解。 仍然需要使其可重现和集中。
再现性(文字扩展)
文字扩展模式与可复制性没有直接关系,但是它将帮助我们实现这一目标。 目前,我们的问题是我们使用
DateTime.Now
日期作为订购日期。 如果突然从某个日期开始,订单接受的逻辑发生了变化,那么在我们的业务逻辑中,我们将至少必须在一段时间内支持两种订单接受逻辑,并通过检查
if (order.Date > edgeDate)
将它们分开。 在这种情况下,当系统日期通过边界时,我们的测试就有机会下降。 是的,我们将快速解决此问题,甚至进行两项测试:一项将在边界日期之前检查逻辑,另一项将在边界日期之后进行。 但是,最好避免这种情况,并立即使所有输入数据恒定。
“ DSL在哪里?” -你问。 事实是通过扩展方法在测试中输入日期很方便,例如
3.May(2019)
。 这种记录方式不仅对于开发人员而且对于企业都是可以理解的。 为此,只需创建一个静态类
public static class DateConstructionExtensions { public static DateTime May(this int day, int year) => new DateTime(year, 5, day); }
当然,日期并不是唯一使用此模式的对象。 例如,如果我们在产品的成分中引入成分的数量,我们可以写成
42.Grams("flour")
。
通过熟悉的扩展方法可以方便地创建定量对象和日期。
重点
为什么保持测试重点很重要? 事实是,重点测试更易于维护,但仍然必须得到支持。 例如,在更改代码时必须更改它们,而在看到旧功能时必须删除它们。 如果测试不集中,则在更改逻辑时,您将需要理解大型测试,并从中切出部分经过测试的功能。 如果测试重点突出并且名称明确,则只需要删除过时的测试并编写新的即可。 如果测试具有良好的DSL,那么这根本不是问题。
因此,在完成DSL的编写之后,我们就有机会通过将其分为2个测试来使测试重点化:
[Fact] public void WhenAcceptOrder_AddIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyAddWasCalledWith(order); } [Fact] public void WhenAcceptOrder_ReserveIngredientsIsCalled() { var order = Create.Order .Dated(3.May(2019)) .With(Pizza.Pepperoni.CountOf(1).For(500)) .With(Pizza.Margarita.CountOf(1).For(650)) .Please(); var service = Create.PizzeriaService.Please(); service.AcceptOrder(order); service.VerifyReserveIngredientsWasCalledWith(order); }
两项测试都证明是简短,清晰,可重现和集中的。
请注意,现在测试的名称反映了编写它们的目的,现在进入我项目的所有开发人员都将理解为什么编写每个测试以及此测试中发生了什么。
测试的重点使它们得到支持。 一个好的测试必须重点关注。
现在,我已经可以听到你对我大喊大叫:“ Y,你他妈的是什么? 我们写了一百万个代码只是为了使几个测试漂亮?” 是的,完全正确。 尽管我们只有几个测试,但是投资DSL并使这些测试易于理解是有意义的。 编写DSL之后,您将获得很多好处:
- 编写新测试变得容易。 无需花费2个小时就可以进行单元测试,只需编写并撰写。
- 测试变得可以理解和可读。 任何看测试的开发人员都知道为什么要编写测试以及要检查什么。
- 降低了新开发人员加入测试的门槛(可能在领域内)。 例如,通过ObjectMother,您可以轻松确定可以在域中创建哪些对象。
- 最后,可以很好地进行测试,因此,代码得到了更多的支持。
示例源代码和测试可
在此处获得 。