《领域特定语言》一3.6 测试DSL

3.6 测试DSL

过去二十年,我变得越来越不想谈论测试。我已然成为一个忠实粉丝,迷恋着测试驱动开发 [Beck TDD]以及类似的技术:将测试置于程序设计之前。所以,我已无法脱离测试思考DSL。
对DSL而言,我把其测试分为三个独立的部分:“语义模型”(第11章)的测试,解析器的测试,以及脚本的测试。

3.6.1语义模型的测试

我首先想到的部分是“语义模型”(第11章)的测试。这些测试用来保证语义模型能够如预期般工作,也就是说,当执行模型时,根据编写的代码,它能够产生正确的输出。这是一个标准的测试实践,同测试任何框架里的对象一样。对于这种测试,根本无需DSL。使用模型本身的基本接口就可以组装模型。这种做法很好,因为可以独立测试模型,无须DSL和解析器。
我们用秘密面板控制器说明这种做法。在这个例子中,语义模型就是状态机。下面测试语义模型,用1.3节的例子提及的命令–查询API组装模型,无需任何DSL。

@Test
public void event_causes_transition() {
  State idle = new State("idle");
  StateMachine machine = new StateMachine(idle);
  Event cause = new Event("cause", "EV01");
  State target = new State("target");
  idle.addTransition(cause, target);
  Controller controller = new Controller(machine, new CommandChannel());
  controller.handle("EV01");
  assertEquals(target, controller.getCurrentState());
}

上面的代码演示了如何独立测试语义模型。然而,需要说明的是,这个例子的真实测试代码会更复杂,也应该更好地分解。
有两种方法来分解这类代码。首先,创建一堆小的状态机,提供最小的测试夹具,以便测试语义模型的各种特性。比如,要测试“事件触发转换”(event triggers a transition),只要创建一个简单状态机,它处于空闲态,并且可以转换(transition)为另外两个状态。

class TransitionTester...
  State idle, a, b;
  Event trigger_a, trigger_b, unknown;

  protected StateMachine createMachine() {
    idle = new State("idle");
    StateMachine result = new StateMachine(idle);
    trigger_a = new Event("trigger_a", "TRGA");
    trigger_b = new Event("trigger_b", "TRGB");
    unknown = new Event("Unknown", "UNKN");
    a = new State("a");
    b = new State("b");
    idle.addTransition(trigger_a, a);
    idle.addTransition(trigger_b, b);
    return result;
  }

如果要测试命令(command),也许只要一个更小的状态机,它只有一个空闲态。

class CommandTester...
  Command commenceEarthquake = new Command("Commence Earthquake", "EQST");
  State idle = new State("idle");
  State second = new State("second");
  Event trigger = new Event("trigger", "TGGR");

  protected StateMachine createMachine() {
    second.addAction(commenceEarthquake);
    idle.addTransition(trigger, second);
    return new StateMachine(idle);
  }

这些不同的夹具可以用类似的方法运行,给它们创建一个共同的超类会让这一切更加容易。这个超类首先应该能够创建公用夹具 ─在这个初始化过程里,包括一个控制器(controller)、一个命令通道(command channel),还有子类提供的状态机。

class AbstractStateTesterLib...
  protected CommandChannel commandChannel = new CommandChannel();
  protected StateMachine machine;
  protected Controller controller;

  @Before
  public void setup() {
    machine = createMachine();
    controller = new Controller(machine, commandChannel);
  }

  abstract protected StateMachine createMachine();

下面编写测试,在控制器中触发事件,然后检查状态。

class TransitionTester...
  @Test
  public void event_causes_transition() {
    fire(trigger_a);
    assertCurrentState(a);
  }
  @Test
  public void event_without_transition_is_ignored() {
    fire(unknown);
    assertCurrentState(idle);
  }

class AbstractStateTesterLib...
  //-------- Utility methods --------------------------
  protected void fire(Event e) {
    controller.handle(e.getCode());
  }
  //------- Custom asserts --------------------------
  protected void assertCurrentState(State s) {
    assertEquals(s, controller.getCurrentState());
  }

超类提供的Test Utility Method [Meszaros] 和Custom Assertion [Meszaros] 让测试更易读。
另一种测试语义模型的方法是组装一个拥有很多特性的大模型,然后进行多方面的测试。在下面的例子里,我用格兰特小姐的控制器作为测试夹具。

class ModelTest...
  private Event doorClosed, drawerOpened, lightOn, doorOpened, panelClosed;
  private State activeState, waitingForLightState, unlockedPanelState,
                idle, waitingForDrawerState;
  private Command unlockPanelCmd, lockDoorCmd, lockPanelCmd, unlockDoorCmd;
  private CommandChannel channel = new CommandChannel();
  private Controller con;
  private StateMachine machine;
@Before
public void setup() {
  doorClosed = new Event("doorClosed", "D1CL");
  drawerOpened = new Event("drawerOpened", "D2OP");
  lightOn = new Event("lightOn", "L1ON");
  doorOpened = new Event("doorOpened", "D1OP");
  panelClosed = new Event("panelClosed", "PNCL");
  unlockPanelCmd = new Command("unlockPanel", "PNUL");
  lockPanelCmd = new Command("lockPanel", "PNLK");
  lockDoorCmd = new Command("lockDoor", "D1LK");
  unlockDoorCmd = new Command("unlockDoor", "D1UL");

  idle = new State("idle");
  activeState = new State("active");
  waitingForLightState = new State("waitingForLight");
  waitingForDrawerState = new State("waitingForDrawer");
  unlockedPanelState = new State("unlockedPanel");

  machine = new StateMachine(idle);

  idle.addTransition(doorClosed, activeState);
  idle.addAction(unlockDoorCmd);
  idle.addAction(lockPanelCmd);

  activeState.addTransition(drawerOpened, waitingForLightState);
  activeState.addTransition(lightOn, waitingForDrawerState);

  waitingForLightState.addTransition(lightOn, unlockedPanelState);
  waitingForDrawerState.addTransition(drawerOpened, unlockedPanelState);

  unlockedPanelState.addAction(unlockPanelCmd);
  unlockedPanelState.addAction(lockDoorCmd);
  unlockedPanelState.addTransition(panelClosed, idle);

  machine.addResetEvents(doorOpened);
  con = new Controller(machine, channel);
  channel.clearHistory();
}
@Test
public void event_causes_state_change() {
  fire(doorClosed);
  assertCurrentState(activeState);
}

@Test
public void ignore_event_if_no_transition() {
  fire(drawerOpened);
  assertCurrentState(idle);
}

在这个例子里,我又一次用到了自己的命令–查询接口组装语义模型。然而,随着测试夹具变得复杂,我会考虑用DSL 创建测试夹具,以简化测试。如果我的解析器有测试,我就可以这么做。

3.6.2解析器的测试

当使用“语义模型”(第11章)时,解析器的工作就是组装语义模型。所以,解析器的测试就是,编写一小段DSL,确保它们生成结构正确的语义模型。

@Test
public void loads_states_with_transition() {
  String code =
    "events trigger TGGR end " +
    "state idle " +
    "trigger => target " +
    "end " +
    "state target end ";
  StateMachine actual = StateMachineLoader.loadString(code);

  State idle = actual.getState("idle");
  State target = actual.getState("target");
  assertTrue(idle.hasTransition("TGGR"));
  assertEquals(idle.targetState("TGGR"), target);
}

这样使用语义模型不太合适,而且可能破坏语义模型对象的封装。所以,还有一种方法是,定义一些方法,比较语义模型,使用这些方法来测试解析器的输出。

@Test
public void loads_states_with_transition_using_compare() {
  String code =
    "events trigger TGGR end " +
    "state idle " +
    "trigger => target " +
    "end " +
    "state target end ";
  StateMachine actual = StateMachineLoader.loadString(code);

  State idle = new State("idle");
  State target = new State("target");
  Event trigger = new Event("trigger", "TGGR");
  idle.addTransition(trigger, target);
  StateMachine expected = new StateMachine(idle);

  assertEquivalentMachines(expected, actual);
}

相比于常规的相等性判定,复杂结构的相等性判定更为复杂。要了解对象之间的具体差异,一个布尔(Boolean)类型的答案是远远不够的。所以,要用“通知”(第16章)进行比较。

class StateMachine...
  public Notification probeEquivalence(StateMachine other) {
    Notification result = new Notification();
    probeEquivalence(other, result);
    return result;
  }

  private void probeEquivalence(StateMachine other, Notification note) {
    for (State s : getStates()) {
      State otherState = other.getState(s.getName());
      if (null == otherState) note.error("missing state: %s", s.getName()) ;
      else s.probeEquivalence(otherState, note);
    }
    for (State s : other.getStates())
      if (null == getState(s.getName())) note.error("extra state: %s", s.getName());
    for (Event e : getResetEvents()) {
      if (!other.getResetEvents().contains(e))
        note.error("missing reset event: %s", e.getName());
    }
    for (Event e : other.getResetEvents()) {
      if (!getResetEvents().contains(e))
        note.error("extra reset event: %s", e.getName());
    }
  }
class State...
  void probeEquivalence(State other, Notification note) {
    assert name.equals(other.name);
    probeEquivalentTransitions(other, note);
    probeEquivalentActions(other, note);
  }

  private void probeEquivalentActions(State other, Notification note) {
    if (!actions.equals(other.actions))
      note.error("%s has different actions %s vs %s", name, actions, other.actions);
  }

  private void probeEquivalentTransitions(State other, Notification note) {
    for (Transition t : transitions.values())
      t.probeEquivalent(other.transitions.get(t.getEventCode()), note);
    for (Transition t : other.transitions.values())
      if (!this.transitions.containsKey(t.getEventCode()))
        note.error("%s has extra transition with %s", name, t.getTrigger());
  }

这种检测方式就是遍历语义模型中的对象,然后把差异记录在通知中。这样,就可以找出所有的差异,而不是找到 第一个就停下来。断言只要检查通知中是否有错误即可。

class AntlrLoaderTest...
  private void assertEquivalentMachines(StateMachine left, StateMachine right) {
    assertNotificationOk(left.probeEquivalence(right));
    assertNotificationOk(right.probeEquivalence(left));
  }

  private void assertNotificationOk(Notification n) {
    assertTrue(n.report(), n.isOk());
  }

class Notification...
  public boolean isOk() {return errors.isEmpty();}

你可能会认为我是一个偏执狂,要从两个方向进行相等性断言,但事实上,代码经常会出乎所料。
无效输入的测试
刚才讨论的是正向测试,保证有效的DSL输入可以生成结构正确的“语义模型”(第11章)。测试的另一种类别是负向测试,用于检测在无效输入的情况下会发生什么。这还会涉及错误处理和诊断等技术,这些内容超出了本书的范围,但我还是要在这里简单地讨论对无效输入的测试。
无效输入的测试的基本想法,就是把各式各样的无效输入抛给解析器。第一次进行这样的测试会非常有趣。我们经常会看到一些不起眼却很极端的错误。得到这样的结果可能已经足够了,除非我们要对错误诊断提供更多的支持。更糟糕的情况是,提供无效输入、解析,根本没有任何错误。这违反了“快速失败”(fail fast)原则─也就是说,错误应该尽快、尽可能明显地暴露出来。如果用无效状态组装一个模型,又没有任何检查,那么可能要到很晚才会发现问题。到了那个时候,原始的错误(加载无效输入)和后来的失败之间已然相去甚远,这段距离会让错误定位难上加难。
状态机例子只有很少的错误处理机制─这是本书中一个典型的例子。用下面这个测试来测试解析器例子,看看会发生什么:

@Test public void targetStateNotDeclaredNoAssert () {
  String code =
    "events trigger TGGR end " +
    "state idle " +
    "trigger => target " +
    "end ";
  StateMachine actual = StateMachineLoader.loadString(code);
}

虽然测试通过了,但情况非常糟糕。稍后,我尝试用模型做些事情,即便只是简单的打印工作,它都会抛出空指针异常。这个例子有些粗糙,我可以接受,毕竟它只是用于教学,但是,输入DSL中的一个拼写错误都要耗费大量时间调试。这是我的时间,我喜欢假装时间很宝贵,所以,我希望它能够快速失败。
问题在于,创建了一个无效结构的语义模型,所以,检查这个错误也是语义模型的职责所在─在这个例子中,就是要在给状态(state)添加转换(transition)的方法里进行处理。添加一个断言检测这个问题。

class State...
  public void addTransition(Event event, State targetState) {
    assert null != targetState;
    transitions.put(event.getCode(), new Transition(this, event, targetState));
  }

现在,就可以修改测试捕获异常了。如果更改输入的行为,它就会告诉我,还会记录是怎样的非法输入带来的问题。

@Test public void targetStateNotDeclared () {
  String code =
    "events trigger TGGR end " +
    "state idle " +
    "trigger => target " +
    "end ";
  try {
    StateMachine actual = StateMachineLoader.loadString(code);
    fail();
  } catch (AssertionError expected) {}

你会注意到,我只给目标状态添加了断言,而没有断言触发事件,它同样也可能为空。这么做的原因是,空事件在调用event.getCode()时,会立即抛出空指针异常。这就满足了快速失败的要求。可以用另外一个测试检查这个问题。

@Test public void triggerNotDeclared () {
  String code =
    "events trigger TGGR end " +
    "state idle " +
    "wrongTrigger => target " +
    "end " +
    "state target end ";
  try {
    StateMachine actual = StateMachineLoader.loadString(code);
    fail();
  } catch (NullPointerException expected) {}

空指针异常确实能够快速失败,但是它不如断言那么清晰。一般来说,我不会对方法实参进行非空断言,我觉得,因为要额外阅读代码,所以这种做法带来的好处有些不值得。除非一段为空的处理不能立即失败,就像上面的空目标状态一样。

3.6.3脚本的测试

“语义模型”(第11章)和解析器的测试就是对普通代码进行单元测试。然而,DSL脚本也是代码,我们也应该考虑对它们进行测试。我经常听到这样的观点:“DSL脚本过于简单和明显,不值得测试”,但我本能地对这种想法存疑。我把测试视 为一种“双重检查”(double–check)机制。当编写代码和测试时,其实是用两种非常不同的方式确定同一行为,一种是用抽象的方式(代码),另一种是用样例的方式(测试)。对任何有持久价值的东西,我们都应该进行双重确认。
脚本测试的细节很大程度上取决于要测试的东西。基本的方法是,提供一个测试环境,在其中创建文本夹具,运行 DSL,比较结果。准备这样的环境需要花费一些精力,不过,DSL易读,并不意味人们就不会犯错误。如果不提供这样的环境,没有双重检查机制,会极大地增加在DSL中犯错误的风险。
脚本测试也扮演着集成测试的角色,因为解析器或者语义模型的任何错误都会让它失败。所以,选择一些DSL脚本用于此目的是值得的。
通常,对于测试和调试DSL脚本而言,脚本可视化是一种非常有用的辅助手段。如果脚本已经置入语义模型,那么对脚本的逻辑,生成不同的可视化方式(文本或图形化)相对容易。以多种方式呈现有助于人们发现错误,确实,这种双重检查的想法,就是自动测试代码如此有价值的核心原因。
对于状态机这个例子,我会先想出几个对于这类状态机来说有意义的场景。对我来说,合理的方法就是运行这些场景,每个场景都是一连串发送给状态机的事件。然后,检查每个状态机的最终状态,以及发出的命令。以更加可读的方式构造这样的测试,其实就创建了另一套DSL。这并不奇怪,测试脚本其实就是一种DSL,因为它很好地满足了受限的、声明式的语言要求。

events("doorClosed", "drawerOpened", "lightOn")
        .endsAt("unlockedPanel")
        .sends("unlockPanel", "lockDoor");
时间: 2024-09-30 03:47:45

《领域特定语言》一3.6 测试DSL的相关文章

《领域特定语言》一2.5 DSL的生命周期

2.5 DSL的生命周期 为了介绍DSL,开篇先描述一个框架,及其命令–查询API,基于这个API,构建一层DSL以简化操作.我用这种方式,是因为我觉得这种方式有助于理解DSL,但这并不是人们在实际中使用DSL的唯一方式.另一种常见的方式是先定义DSL.在这种模式下,可以先从一些场景开始,按照期望DSL的样子,把这些场景写下来.如果语言是业务功能的一部分,最好和领域专家一起做─这是一个好的开始,使用DSL作为一种沟通媒介.有人喜欢从语句开始,他们期待这些语句能够语法正确.这意味着,对内部DSL,

《领域特定语言》一3.8 DSL迁移

3.8 DSL迁移 DSL拥趸们应该警惕的一个风险是"先编写,后使用"的想法.同其他软件一样,成功的DSL需要不断演化.也就是说,以早期版本DSL编写的脚本可能无法在新版上运行.DSL的诸多属性(无论好坏)同程序库完全一样,这一点也不例外.如果从别人那里得到一个程序库,基于它编写一些代码,他们升级了程序库,我们可能最终就卡在那里.DSL并不会真正改变这一点:本质上DSL定义的就是已发布接口(published interface),我们不得不自行处理这个后果.在重构 [Fowler R

如何设计一门编程语言(十) 正则表达式与领域特定语言(DSL)

几个月前就一直有博友关心DSL的问题,于是我想一想,我在gac.codeplex.com里面也创建了一些DSL,于是今天就来说一说这个事情. 创建DSL恐怕是很多人第一次设计一门语言的经历,很少有人一开始上来就设计通用语言的.我自己第一次做这种事情是在高中写这个傻逼ARPG的时候了.当时做了一个超简单的脚本语言,长的就跟汇编差不多,虽然每一个指令都写成了调用函数的形态.虽然这个游戏需要脚本在剧情里面控制一些人物的走动什么的,但是所幸并不复杂,于是还是完成了任务.一眨眼10年过去了,现在在写Gac

《领域特定语言》一第2章 使用DSL 2.1定义DSL

第2章 使用DSL 看过上一章的例子后,即便尚未给出DSL的一般定义,对于何为DSL,你也应该已经有了自己的认识.(第10章中有更多例子.)现在,要开始讨论DSL的定义及其优势与问题.这样就可以为下一章讨论DSL实现提供一些上下文. 2.1定义DSL "领域特定语言"是一个很有用的术语和概念,但其边界很模糊.一些东西很明显是DSL,但另一些可能会引发争议.这一术语由来已久,不过,正如软件行业中的很多东西一样,它也从未有过一个确切的定义.然而,就本书而言,定义是非常有价值的.领域特定语言

《领域特定语言》一导读

前 言 在我开始编程之前,DSL(Domain–Specific Language,领域特定语言)就已经成了程序世界中的一员.随便找个UNIX或者Lisp老手问问,他一定会跟你滔滔不绝地谈起DSL是怎么成为他的镇宅之宝的,直到你被烦得痛不欲生为止.但即便这样,DSL却从未成为计算领域的一大亮点.大多数人都是从别人那里学到DSL,而且只学到了有限的几种技术. 我写这本书就是为了改变这个现状.我希望通过本书介绍的大量DSL技术,让你有足够的信息来做出决策:是否在工作中使用DSL,以及选择哪一种DSL

《领域特定语言》一第1章 入 门 例 子1.1 哥特式建筑安全系统

第1章 入 门 例 子 落笔之初,我需要快速地解释一下本书的内容,就是解释什么是领域特定语言(Domain– Specific Language,DSL).为达此目的,我一般都会先展示一个具体的例子,随后再给出抽象的定义.因此,我会从一个例子开始,展示DSL可以采用的不同形式.在第2章里,我会试着把这个定义概括为一些更广泛适用的东西. 1.1 哥特式建筑安全系统 在我的童年记忆里,电视上播放的那些低劣的冒险电影是模糊却持久的.通常,这些电影的场景会安排某个古旧的城堡.密室或走廊在其中起着重要的作

《领域特定语言》一第3章 实现DSL 3.1DSL处理之架构

第3章 实现DSL 至此,对于什么是DSL,以及为何要用DSL,我们已经透彻理解.如果要开始构建DSL,那么现在该深入研究所用的技术了.虽然构建内部DSL和外部DSL所用的技术有所不同,但它们还是有一些共通之处的.本章主要关注内部DSL和外部DSL的一些共通问题,而下一章再讨论各自具体的问题.本章先不谈语言工作台,留待后续探讨. 3.1DSL处理之架构 关于DSL实现的大体结构(见图3-1),也就是所谓的DSL系统架构─可能是我们要谈论的最重要的内容之一. 迄今为止,你应该已经厌倦了听我说了无数

《领域特定语言》一1.3 为格兰特小姐的控制器编写程序

1.3 为格兰特小姐的控制器编写程序 至此,我们已经实现了状态机模型,接下来,就可以为格兰特小姐的控制器编写程序了,如下所示: Event doorClosed = new Event("doorClosed", "D1CL"); Event drawerOpened = new Event("drawerOpened", "D2OP"); Event lightOn = new Event("lightOn&quo

《领域特定语言》一2.4 广义的语言处理

2.4 广义的语言处理 本书是关于领域专用语言的,但它也涉及语言处理技术.之所以二者重合,是因为在普通的开发团队里,用到语言处理技术的情况,90%都是为了DSL.但是,这些技术也可以用于其他方面,若不讨论这些情况,将是我的失职.我曾遇到过这方面一个很好的例子,那是在一次拜访ThoughtWorks项目团队时.他们有一个任务,要与某第三方系统通信,发送的消息以COBOL copybook定义.COBOL copybook是一种用来描述记录的数据结构格式.因为系统中有很多地方要用到,所以我的同事Br