因此,我测试了Dice并添加了解析器(3之2)

仍然是2017年。 我一直在使用GitHub进行开发,我以前写过一篇关于Dice类的文章(读者注意:请参阅上一篇文章)。 本文跟进了我如何测试Dice和实现解析器的方法。 第三部分将介绍如何将其全部放入GitHub。

我知道。 并不令人兴奋。 但是,希望对某些人有所帮助。 或者,对我的未来版本。

总结:关于目标或一般要求,我想代表 2d4 + 1之类的骰子骰 ,这些骰子已 在GitHub中集成和测试 。 在上一篇文章中,我描述了使用沙盒驱动开发( SDD )来设计出一种似乎涵盖了《地牢大师指南》中的用例的设计。 这是描述类型的简化UML,主要类型是名为Dice的协议(接口):

现在,我将认真对待测试,我从Swift Playground过渡到带有Xcode项目的Xcode工作区,其中有两个iOS目标:

  • 一个名为RolePlayingCore的框架,以及
  • 用于测试该框架的单元测试包。

在框架中加入Dice类之后,就该编写一些测试了。

现在,对于一些严重的滴滴涕

我不是TDD的正式从业者,但这不是因为缺乏需求。 这更多是因为习惯,缺乏接触,年纪大了,成为一名经理, yadda yadda yadda 。 我只是喜欢遵循SDD首先解决问题,这意味着在开始编写第一个测试之前我已经有了“代码”。 我喜欢将其视为开发驱动的测试 DDT )。 从好的方面来说,我真的在为我编写的所有代码编写全面的测试,因为我了解到它有助于验证和改进它。

我执行现有代码测试所遵循的心理检查清单是:

  • 从下至上编写测试(核心类型)
  • 测试类和结构生命周期,创建和销毁
  • 测试标称用法和正常输入
  • 测试否定情况,无效输入或抛出错误
  • 以代码覆盖率为指导,测试每个代码路径
  • 测试有趣的事物(特定用例)
  • 测试性能(如果需要)
  • 通过在修改或扩展代码时添加测试和测试点来维护代码覆盖率目标

在进行过多测试之前,我为Test之下的RolePlayingCore方案启用了代码覆盖率“收集覆盖率数据”,以便在编写测试时可以用来指导我:

我要编写测试的第一件事是Die,特别是如何测试滚动Die,以此作为测试Dice组合的起点。 我考虑了一下,决定一起测试Die和RandomNumberGenerator,而不是模拟随机数生成器,或者提出其他一些人为的解决方案。 因此,测试将需要产生平均值,最小值和最大值。 这是我所做的:

  func testRollDie(){ 
print(“ Die d4:”)
//使用d4测试滚动1次
让死亡:Die = .d4
  var sum = 0 
var minValue = 0
var maxValue = 0
对于_ in 0 .. <sampleSize {
让roll = die.roll()
XCTAssertTrue((1 ... 4).contains(roll),
“滚动d4,得到\(roll)”)
总和==
minValue = minValue == 0吗? roll:min(minValue,roll)
maxValue = maxValue == 0吗? roll:max(最大值,滚动)
}
让均值= Double(sum)/ Double(sampleSize)
XCTAssertTrue((2.0 ... 3.0).contains(mean),
“预期平均值约为2.5,得到\(平均值)”)
  XCTAssertEqual(minValue,1,“最小值”) 
XCTAssertEqual(maxValue,4,“最大值”)
  XCTAssertEqual(Die.d4.description,“ d4”,“ d4描述”) 
  //代码覆盖率和测试输出的手动检查: 
print(“平均值= \(平均值)[期望2.5]”)
}

这里发生了几件事。 首先,我需要一个sampleSize常量在测试中进行迭代。 样本大小必须足够大,以使均值落在预期范围内,并产生预期的最小值和最大值。 我在文件顶部附近定义了它,以便可以在多个测试点中使用它:

  ///使用足够大的样本量来达到相对狭窄的范围 
///下面的期望平均值,最小值和最大值。
让sampleSize = 1024

对于已经编写了使用随机数生成器的代码进行测试的读者的问题 :您考虑了哪些方法,建议什么? 您对这种方法有何看法?

“最有效的调试工具仍然是经过仔细考虑,再加上明智地放置的打印语句。” — Brian W. Kernighan

在上面的代码中,您将看到许多打印语句。

万一发生故障,我想比测试点名称更精确一些,然后再测试一下。 如果成功了,我希望可以提供我可以检查的输出,作为一种现实检查。 在这种情况下,测试点正在测试滚动Die,并且输出表明它是d4,其计算出的平均值为2.5,预期平均值为2.5。 我可以使用它来直观地检查计算出的均值是否有意义,即使未触发断言也是如此。 这种情况有点琐碎,但我希望您能想到在较小的情况下进行此操作的价值。

给读者的另一个问题 ,请注意。 但是首先,一些背景。 在开发更多测试代码时,我一遍又一遍地重复了这一过程,而不是将for循环和均/最小/最大逻辑提升为一个单独的函数。 我这样做的原因是,当测试失败时,我发现将共享功能而不是测试点报告为失败是令人困惑的。 但这感觉不对。 问题 :这有意义吗? 警告 :请记住,我没有太多编写测试代码的经验; 如果您有更好的主意,请提供。

变得棘手

这种DDT方法不如TDD强大。 您真的可以自欺欺人地认为您编写的测试是好的,因为它们从一开始就成功。 因此,在为现有代码编写测试时,我喜欢自己玩一些技巧:

  • 故意测试错误的东西,错误的结果,以确保它确实失败,并且
  • 故意破坏工作代码,以查看是否确实捕获了我认为要测试的内容。

例如,在d4的情况下,我可能故意测试最小为5,或将实现原始值更改为5。我喜欢对我编写的绝大多数测试点进行此操作,以体验失败,看看它自己该说些什么。 如果查看通过的单元测试的代码覆盖率,则会发现没有覆盖任何单元测试的失败代码本身。 ! 因为成功意味着没有失败! 因此,通过故意破坏事物,对于新的测试点至少要运行一次强制失败。

预期:1,得到:0

现在,我正在观察故障,我尝试花费一些时间来确保故障消息有意义。 有时,我会在故障消息中附加其他内容,以便在实际触发故障的几个月或几年后,我还有很多工作要做。 您会发现我使用平均值声明做了一个小小的尝试,因为这可能不足以了解发生了什么与预期相比。

补充工具栏:在我曾任职的一家公司中,成千上万的测试会失败地报告“预期为1,得到0”,这和泥浆一样有用。 因此,我再说一遍: 确保失败消息有意义 。 当由于某些意外原因而导致实际失败时,这为我节省了很多时间。

我尝试做的另一件事是确保测试点完全覆盖我要测试的所有内容。 我不想错过在下游间接失效的部分。 因此,在开发测试时,我只能通过单击测试左侧的菱形来运行此单元测试或单个测试点,然后再检查相应的类或文件的覆盖范围:

有了这些,我很快就为SimpleDice,DroppingDice和CompoundDice的多个输入编写了测试点,并重复了平均值,最小值和最大值的乏味重复。

关于这一点,编写所有这些测试点变得很繁琐,因此,当然,对我而言发生的下一件事是:特征蠕变!

好极了! 功能蔓延! 更多代码! 所有代码!

我非常想要一个解析器。 取一个任意的骰子表达式,例如“ 2d6 + 1”,并生成相关的骰子。 我已经在操场上闲逛着一个解析器,但是还没准备好黄金时段。 该界面如下所示:

 公共功能骰子(来自字符串:String)->骰子? 

界面是最简单的部分。 好吧,除了我更喜欢将它放在其他地方,例如Dice init()函数或String扩展? 一个免费的功能目前已经足够好了。 困难的部分是它将接受什么样的输入。 我可能希望按照[]d[|-]*解析某些内容(或类似的东西(我实际上不知道如何表达这些东西,我只是编造的)。 示例可能包括“ d8”,“ 2d12 + 2”,“ 4d6-L”,“ 1”,“ 2d4 + 3d12–4”。

接下来是垃圾。 孩子们,不要在家做这个。

现在,让我们记住我的背景。 算法是我的致命弱点 ,我的K石 。 让我们忽略这个事实,我曾在三家不同的公司工作过,这些公司实施了不同程度的高度复杂的解析器。 重要的部分是,我不必执行任何一个,而我几乎不需要维护所接触到的那些。

我确实考虑了Jerry Weinberg的“三个规则”( Jerry Weinberg,质量软件管理第2卷,第6章),并评估了三种不同的潜在解决方案:

  • 使用我知道的东西,例如String range(of 🙂
  • 开发一个完整的解析器
  • 使用NSRegularExpression

最终,我接受了我所知道的。 这适用于我已经拥有的测试用例。 这是解析器的基本轮廓:

 公共功能骰子(来自字符串:String)->骰子?  { 
var dice:骰子?

//最常见的情况是骰子带有“ d”的字符串。
如果让dRange = string.range(of:“ d”){
//如果“ d”不是从开头开始,请解释
//前缀“ d”之前的次数。
let times = string.parseDiceTimes(before:dRange)?? 1个

//如果后缀为“ -L”或“ -H”,请解释
//后缀为下降。
var(dropping,endIndex)= string.parseDiceDropping()

//如果未删除,请尝试数学运算符。
var操作数:骰子?
var mathOperator:字符串=“ +”
如果掉落== nil {
(操作数,mathOperator,endIndex)=
string.parseDiceMath()
}

//解释骰子编号在“ d”之后和之前
//数学运算符(如果提供)。
让diceRange = dRange.upperBound .. <endIndex
守卫让边= string.parseDiceSides(in:diceRange)else
{return nil}
后卫让死= Die(rawValue:双方)else {return nil}
如果操作数== nil {
如果掉落== nil {
骰子= SimpleDice(die,times:times)
}其他{
骰子= DroppingDice(die,times:times,
掉落:掉落!)
}
}其他{
骰子= CompoundDice(lhs:SimpleDice(die,times:times),
rhs:操作数!,
mathOperator:mathOperator)
}
}其他{
骰子=修饰符(来自:字符串)
}

返回骰子
}

这是帮助功能。 范围,子字符串和元组,哦,我的!

  //用于解析骰子格式字符串的扩展名。 
私有扩展字符串{

///返回“ d”之前的数字(如果存在),
///否则返回nil。
func parseDiceTimes(在dRange之前:Range )->
真的吗 {
守护dRange.lowerBound!= self.startIndex,
let times = Int(self.substring(至:
dRange.lowerBound))else
{return nil}
返回时间
}

///以Int形式返回子字符串范围,如果返回则为100
///字符串是“%”。 如果字符串不能返回nil
///转换为数字。
func parseDiceSides(在diceRange:范围)->整数? {
让sidelinesString = self.substring(with:diceRange)
返回sidesString ==“%”? 100:Int(sidesString)
}

///如果字符串中包含受支持的数学运算符,并且
///如果可以将字符串的操作数转换为Dice,
///返回操作数,数学运算符和下限
///运算符之前的绑定索引。
func parseDiceMath()->(Dice ?, String,String.Index){
守卫让范围= self.rangeOfCharacter(来自:
mathOperatorCharacters),
让操作数=骰子(来自:self.substring(来自:
range.upperBound))else
{return(nil,“ +”,self.endIndex)}
返回(操作数,self [range],range.lowerBound)
}

///如果字符串中包含受支持的数学运算符,并且
///如果可以将字符串的操作数转换为
/// Dice修饰符,返回操作数,数学
///运算符,以及下限索引在运算符之前。
func parseDiceModifier()->(Dice ?, String,String.Index){
守卫让范围= self.rangeOfCharacter(来自:
mathOperatorCharacters),
让操作数=修饰符(来自:self.substring(来自:
range.upperBound))else
{return(nil,“ +”,self.endIndex)}
返回(操作数,self [range],range.lowerBound)
}

func parseDiceDropping()->(DroppingDice.Dropping ?,
String.Index){
var解析:DroppingDice.Dropping?
var operatorIndex = self.endIndex

用于放置DroppingDice.Dropping.allValues {
如果让range = self.range(of:dropping.rawValue),
range.upperBound == self.endIndex {
解析=删除
operatorIndex = range.lowerBound
}
}

返回(已解析,operatorIndex)
}

私有函数修饰符(来自字符串:String)->骰子? {
var dice:骰子?
//只是数字修饰符吗?
如果让修饰符= Int(string){
骰子= DiceModifier(修饰符)
}其他{
//尝试数学运算符。
let(修饰符,mathOperator,endIndex)=
string.parseDiceModifier()
如果修饰符!= nil,
让leftNumber = Int(string.substring(with:
string.startIndex .. <endIndex)){
骰子= CompoundDice(lhs:DiceModifier(leftNumber),
rhs:修饰符!,
mathOperator:mathOperator)
}
}
返回骰子
}
}

这似乎很简单,但是编写它是个人的噩梦。 极度不适。 如图所示,“立即辞职; 将职业视为咖啡师。” 嘿,别敲它。 我有可能每天都做拿铁咖啡。 我只允许自己实现这个所谓的解析器,因为它只有一百多行代码,比完整的解析器要少得多的代码,而我只是无法弄清NSRegularExpression给自己玩。 另外,我觉得自己有机会维持它。

但是,就像上一篇文章中Dice的原始结构一样,这种最初的解析器实现最终失败了。

千种死亡组合

杀死我的第一个字符串表达式是“ 2d4 + d12–2 + 5”。看到那里的减号了吗?

是的

解析器需要能够产生一些保留运算符顺序的东西。 我最初的解析器没有。 但是,我最终的目标是进入GitHub,时钟一直在滴答滴答,所以就目前而言,我将在文件顶部写一个冗长的理由让这成为目前的最终答案:

  // TODO:这避免了现在使用完整的解析器或regexp; 
//仅约100行,并且支持约95%的用例
//用于指定大多数事物的复合骰子掷骰,
//包括不同的骰子,修饰符,并放下
//多次滚动的最高或最低。
//
//这不适用于常规解析
//但是,嵌套的复合骰子卷; 预计
//游戏规则引擎可能会更简单地组合
//直接将骰子掷骰子和修饰符,可以提供
//以下主要优点:
//
//-保持关注点分离(每个骰子所有者)
// roll负责其角色)
//-允许缓存和内省中间卷

借口,借口。

我也将致命的字符串表达式留在了单元测试中作为单元测试点,注释掉了。 随后,我将删除注释并实现一个更好的解析器,以允许通过单元测试点,因为我确实确实希望使用更通用的解析器,但是我会将其保存在本系列文章的3部分中,其中我实际上开始在GitHub中进行集成和测试 。 (最后。)

好。 测试和解析器就足够了。